diff options
Diffstat (limited to 'devtools/shared/resources')
84 files changed, 9972 insertions, 0 deletions
diff --git a/devtools/shared/resources/legacy-listeners/cache-storage.js b/devtools/shared/resources/legacy-listeners/cache-storage.js new file mode 100644 index 0000000000..d800e87616 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/cache-storage.js @@ -0,0 +1,18 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const { + makeStorageLegacyListener, +} = require("devtools/shared/resources/legacy-listeners/storage-utils"); + +module.exports = makeStorageLegacyListener( + "Cache", + ResourceWatcher.TYPES.CACHE_STORAGE +); diff --git a/devtools/shared/resources/legacy-listeners/console-messages.js b/devtools/shared/resources/legacy-listeners/console-messages.js new file mode 100644 index 0000000000..c784147154 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/console-messages.js @@ -0,0 +1,54 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetList, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + const listenForFrames = targetList.targetFront.isLocalTab; + + // Allow workers when messages aren't dispatched to the main thread. + const listenForWorkers = !targetList.rootFront.traits + .workerConsoleApiMessagesDispatchedToMainThread; + + const acceptTarget = + targetFront.isTopLevel || + targetFront.targetType === targetList.TYPES.PROCESS || + (targetFront.targetType === targetList.TYPES.FRAME && listenForFrames) || + (targetFront.targetType === targetList.TYPES.WORKER && listenForWorkers); + + if (!acceptTarget) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages + await webConsoleFront.startListeners(["ConsoleAPI"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners(ConsoleAPI) first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["ConsoleAPI"]); + + for (const message of messages) { + message.resourceType = ResourceWatcher.TYPES.CONSOLE_MESSAGE; + } + onAvailable(messages); + + // Forward new message events + webConsoleFront.on("consoleAPICall", message => { + message.resourceType = ResourceWatcher.TYPES.CONSOLE_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/resources/legacy-listeners/cookie.js b/devtools/shared/resources/legacy-listeners/cookie.js new file mode 100644 index 0000000000..8fe2201ab6 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/cookie.js @@ -0,0 +1,18 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const { + makeStorageLegacyListener, +} = require("devtools/shared/resources/legacy-listeners/storage-utils"); + +module.exports = makeStorageLegacyListener( + "cookies", + ResourceWatcher.TYPES.COOKIE +); diff --git a/devtools/shared/resources/legacy-listeners/css-changes.js b/devtools/shared/resources/legacy-listeners/css-changes.js new file mode 100644 index 0000000000..f9bc0e9547 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/css-changes.js @@ -0,0 +1,30 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetFront, onAvailable }) { + if (!targetFront.hasActor("changes")) { + return; + } + + const changesFront = await targetFront.getFront("changes"); + + // Get all changes collected up to this point by the ChangesActor on the server, + // then fire each change as "add-change". + const changes = await changesFront.allChanges(); + await onAvailable(changes.map(change => toResource(change))); + + changesFront.on("add-change", change => onAvailable([toResource(change)])); +}; + +function toResource(change) { + return Object.assign(change, { + resourceType: ResourceWatcher.TYPES.CSS_CHANGE, + }); +} diff --git a/devtools/shared/resources/legacy-listeners/css-messages.js b/devtools/shared/resources/legacy-listeners/css-messages.js new file mode 100644 index 0000000000..1f71ff6777 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/css-messages.js @@ -0,0 +1,66 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); +const { MESSAGE_CATEGORY } = require("devtools/shared/constants"); + +module.exports = async function({ targetList, targetFront, onAvailable }) { + // Allow the top level target if the targetFront has an `ensureCSSErrorREportingEnabled` + // function. Also allow frame targets. + const isAllowed = + typeof targetFront.ensureCSSErrorReportingEnabled == "function" && + (targetFront.isTopLevel || + targetFront.targetType === targetList.TYPES.FRAME); + + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new CSS messages (they're emitted from the "PageError listener"). + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["PageError"]); + + const cachedMessages = []; + for (const message of messages) { + if (message.pageError?.category !== MESSAGE_CATEGORY.CSS_PARSER) { + continue; + } + + message.resourceType = ResourceWatcher.TYPES.CSS_MESSAGE; + message.cssSelectors = message.pageError.cssSelectors; + delete message.pageError.cssSelectors; + cachedMessages.push(message); + } + + onAvailable(cachedMessages); + + // CSS Messages are emited fron the PageError listener, which also send regular errors + // that we need to filter out. + webConsoleFront.on("pageError", message => { + if (message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER) { + return; + } + + message.resourceType = ResourceWatcher.TYPES.CSS_MESSAGE; + message.cssSelectors = message.pageError.cssSelectors; + delete message.pageError.cssSelectors; + onAvailable([message]); + }); + + // Calling ensureCSSErrorReportingEnabled will make the server parse the stylesheets to + // retrieve the warnings if the docShell wasn't already watching for CSS messages. + await targetFront.ensureCSSErrorReportingEnabled(); +}; diff --git a/devtools/shared/resources/legacy-listeners/error-messages.js b/devtools/shared/resources/legacy-listeners/error-messages.js new file mode 100644 index 0000000000..cce1204b73 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/error-messages.js @@ -0,0 +1,64 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); +const { MESSAGE_CATEGORY } = require("devtools/shared/constants"); + +module.exports = async function({ targetList, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + // Also ignore workers as they are not supported yet. (see bug 1592584) + const listenForFrames = targetList.targetFront.isLocalTab; + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetList.TYPES.PROCESS || + (targetFront.targetType === targetList.TYPES.FRAME && listenForFrames); + + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "pageError". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + let { messages } = await webConsoleFront.getCachedMessages(["PageError"]); + + // On server < v79, we're also getting CSS Messages that we need to filter out. + messages = messages.filter( + message => message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER + ); + + messages.forEach(message => { + message.resourceType = ResourceWatcher.TYPES.ERROR_MESSAGE; + }); + // Cached messages don't have the same shape as live messages, + // so we need to transform them. + onAvailable(messages); + + webConsoleFront.on("pageError", message => { + // On server < v79, we're getting CSS Messages that we need to filter out. + if (message.pageError.category === MESSAGE_CATEGORY.CSS_PARSER) { + return; + } + + message.resourceType = ResourceWatcher.TYPES.ERROR_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/resources/legacy-listeners/extension-storage.js b/devtools/shared/resources/legacy-listeners/extension-storage.js new file mode 100644 index 0000000000..6ba72fea79 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/extension-storage.js @@ -0,0 +1,18 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const { + makeStorageLegacyListener, +} = require("devtools/shared/resources/legacy-listeners/storage-utils"); + +module.exports = makeStorageLegacyListener( + "extensionStorage", + ResourceWatcher.TYPES.EXTENSION_STORAGE +); diff --git a/devtools/shared/resources/legacy-listeners/indexed-db.js b/devtools/shared/resources/legacy-listeners/indexed-db.js new file mode 100644 index 0000000000..3d24fdcad9 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/indexed-db.js @@ -0,0 +1,19 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const { + // getFilteredStorageEvents, + makeStorageLegacyListener, +} = require("devtools/shared/resources/legacy-listeners/storage-utils"); + +module.exports = makeStorageLegacyListener( + "indexedDB", + ResourceWatcher.TYPES.INDEXED_DB +); diff --git a/devtools/shared/resources/legacy-listeners/local-storage.js b/devtools/shared/resources/legacy-listeners/local-storage.js new file mode 100644 index 0000000000..727ebb54bc --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/local-storage.js @@ -0,0 +1,18 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const { + makeStorageLegacyListener, +} = require("devtools/shared/resources/legacy-listeners/storage-utils"); + +module.exports = makeStorageLegacyListener( + "localStorage", + ResourceWatcher.TYPES.LOCAL_STORAGE +); diff --git a/devtools/shared/resources/legacy-listeners/moz.build b/devtools/shared/resources/legacy-listeners/moz.build new file mode 100644 index 0000000000..ca84256b66 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/moz.build @@ -0,0 +1,24 @@ +# 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/. + +DevToolsModules( + "cache-storage.js", + "console-messages.js", + "cookie.js", + "css-changes.js", + "css-messages.js", + "error-messages.js", + "extension-storage.js", + "indexed-db.js", + "local-storage.js", + "network-event-stacktraces.js", + "network-events.js", + "platform-messages.js", + "root-node.js", + "session-storage.js", + "source.js", + "storage-utils.js", + "stylesheet.js", + "websocket.js", +) diff --git a/devtools/shared/resources/legacy-listeners/network-event-stacktraces.js b/devtools/shared/resources/legacy-listeners/network-event-stacktraces.js new file mode 100644 index 0000000000..ccf70053d5 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/network-event-stacktraces.js @@ -0,0 +1,25 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetList, targetFront, onAvailable }) { + function onNetworkEventStackTrace(packet) { + const actor = packet.eventActor; + onAvailable([ + { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE, + resourceId: actor.channelId, + stacktraceAvailable: actor.cause.stacktraceAvailable, + lastFrame: actor.cause.lastFrame, + }, + ]); + } + const webConsoleFront = await targetFront.getFront("console"); + webConsoleFront.on("serverNetworkStackTrace", onNetworkEventStackTrace); +}; diff --git a/devtools/shared/resources/legacy-listeners/network-events.js b/devtools/shared/resources/legacy-listeners/network-events.js new file mode 100644 index 0000000000..c31139e488 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/network-events.js @@ -0,0 +1,151 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ + targetList, + targetFront, + onAvailable, + onUpdated, +}) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + // Also ignore workers as they are not supported yet. (see bug 1592584) + const listenForFrames = targetList.targetFront.isLocalTab; + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetList.TYPES.PROCESS || + (targetFront.targetType === targetList.TYPES.FRAME && listenForFrames); + + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + const resources = new Map(); + + function onNetworkEvent(packet) { + const actor = packet.eventActor; + + resources.set(actor.actor, { + resourceId: actor.channelId, + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + isBlocked: !!actor.blockedReason, + types: [], + resourceUpdates: {}, + }); + + onAvailable([ + { + resourceId: actor.channelId, + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + timeStamp: actor.timeStamp, + actor: actor.actor, + startedDateTime: actor.startedDateTime, + url: actor.url, + method: actor.method, + isXHR: actor.isXHR, + cause: { + type: actor.cause.type, + loadingDocumentUri: actor.cause.loadingDocumentUri, + }, + timings: {}, + private: actor.private, + fromCache: actor.fromCache, + fromServiceWorker: actor.fromServiceWorker, + isThirdPartyTrackingResource: actor.isThirdPartyTrackingResource, + referrerPolicy: actor.referrerPolicy, + blockedReason: actor.blockedReason, + blockingExtension: actor.blockingExtension, + stacktraceResourceId: + actor.cause.type == "websocket" + ? actor.url.replace(/^http/, "ws") + : actor.channelId, + }, + ]); + } + + function onNetworkEventUpdate(packet) { + const resource = resources.get(packet.from); + + if (!resource) { + return; + } + + const { + types, + resourceUpdates, + resourceId, + resourceType, + isBlocked, + } = resource; + + switch (packet.updateType) { + case "responseStart": + resourceUpdates.httpVersion = packet.response.httpVersion; + resourceUpdates.status = packet.response.status; + resourceUpdates.statusText = packet.response.statusText; + resourceUpdates.remoteAddress = packet.response.remoteAddress; + resourceUpdates.remotePort = packet.response.remotePort; + resourceUpdates.mimeType = packet.response.mimeType; + resourceUpdates.waitingTime = packet.response.waitingTime; + break; + case "responseContent": + resourceUpdates.contentSize = packet.contentSize; + resourceUpdates.transferredSize = packet.transferredSize; + resourceUpdates.mimeType = packet.mimeType; + resourceUpdates.blockingExtension = packet.blockingExtension; + resourceUpdates.blockedReason = packet.blockedReason; + break; + case "eventTimings": + resourceUpdates.totalTime = packet.totalTime; + break; + case "securityInfo": + resourceUpdates.securityState = packet.state; + resourceUpdates.isRacing = packet.isRacing; + break; + } + + resourceUpdates[`${packet.updateType}Available`] = true; + types.push(packet.updateType); + + if (isBlocked) { + // Blocked requests + if ( + !types.includes("requestHeaders") || + !types.includes("requestCookies") + ) { + return; + } + } else if ( + // Un-blocked requests + !types.includes("requestHeaders") || + !types.includes("requestCookies") || + !types.includes("eventTimings") || + !types.includes("responseContent") + ) { + return; + } + + onUpdated([ + { + resourceType, + resourceId, + resourceUpdates, + }, + ]); + } + + webConsoleFront.on("serverNetworkEvent", onNetworkEvent); + webConsoleFront.on("serverNetworkUpdateEvent", onNetworkEventUpdate); + // Start listening to network events + await webConsoleFront.startListeners(["NetworkActivity"]); +}; diff --git a/devtools/shared/resources/legacy-listeners/platform-messages.js b/devtools/shared/resources/legacy-listeners/platform-messages.js new file mode 100644 index 0000000000..725b6963b7 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/platform-messages.js @@ -0,0 +1,46 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetList, targetFront, onAvailable }) { + // Only allow the top level target and processes. + // Frames can be ignored as logMessage are never sent to them anyway. + // Also ignore workers as they are not supported yet. (see bug 1592584) + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetList.TYPES.PROCESS; + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "logMessage". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["LogMessage"]); + + for (const message of messages) { + message.resourceType = ResourceWatcher.TYPES.PLATFORM_MESSAGE; + } + onAvailable(messages); + + webConsoleFront.on("logMessage", message => { + message.resourceType = ResourceWatcher.TYPES.PLATFORM_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/resources/legacy-listeners/root-node.js b/devtools/shared/resources/legacy-listeners/root-node.js new file mode 100644 index 0000000000..f5274305b7 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/root-node.js @@ -0,0 +1,63 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetFront, onAvailable, onDestroyed }) { + // XXX: When watching root node for a non top-level target, this will also + // ensure the inspector & walker fronts for the target are initialized. + // This also implies that we call reparentRemoteFrame on the new walker, which + // will create the link between the parent frame NodeFront and the inner + // document NodeFront. + // + // This is not something that will work when the resource is moved to the + // server. When it becomes a server side resource, a RootNode would be emitted + // directly by the target actor. + // + // This probably means that the root node resource cannot remain a NodeFront. + // It should not be a front and the client should be responsible for + // retrieving the corresponding NodeFront. + // + // The other thing that we are missing with this patch is that we should only + // create inspector & walker fronts (and call reparentRemoteFrame) when we get + // a RootNode which is directly under an iframe node which is currently + // visible and tracked in the markup view. + // + // For instance, with the following markup: + // html + // body + // div + // iframe + // remote doc + // + // If the markup view only sees nodes down to `div`, then the client is not + // currently tracking the nodeFront for the `iframe`, and getting a new root + // node for the remote document should NOT force the iframe to be tracked on + // on the client. + // + // When we get a RootNode resource, we will need a way to check this before + // initializing & reparenting the walker. + // + if (!targetFront.getTrait("isBrowsingContext")) { + // The root-node resource is only available on browsing-context targets. + return; + } + + const inspectorFront = await targetFront.getFront("inspector"); + inspectorFront.walker.on("root-available", node => { + node.resourceType = ResourceWatcher.TYPES.ROOT_NODE; + return onAvailable([node]); + }); + + inspectorFront.walker.on("root-destroyed", node => { + node.resourceType = ResourceWatcher.TYPES.ROOT_NODE; + return onDestroyed([node]); + }); + + await inspectorFront.walker.watchRootNode(); +}; diff --git a/devtools/shared/resources/legacy-listeners/session-storage.js b/devtools/shared/resources/legacy-listeners/session-storage.js new file mode 100644 index 0000000000..177f26d6b0 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/session-storage.js @@ -0,0 +1,18 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const { + makeStorageLegacyListener, +} = require("devtools/shared/resources/legacy-listeners/storage-utils"); + +module.exports = makeStorageLegacyListener( + "sessionStorage", + ResourceWatcher.TYPES.SESSION_STORAGE +); diff --git a/devtools/shared/resources/legacy-listeners/source.js b/devtools/shared/resources/legacy-listeners/source.js new file mode 100644 index 0000000000..5e32287c42 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/source.js @@ -0,0 +1,89 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * Emit SOURCE resources, which represents a Javascript source and has the following attributes set on "available": + * + * - introductionType {null|String}: A string indicating how this source code was introduced into the system. + * This will typically be set to "scriptElement", "eval", ... + * But this may have many other values: + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/dom/script/ScriptLoader.cpp#2628-2639 + * https://searchfox.org/mozilla-central/search?q=symbol:_ZN2JS14CompileOptions19setIntroductionTypeEPKc&redirect=false + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/devtools/server/actors/source.js#160-169 + * - sourceMapBaseURL {String}: Base URL where to look for a source map. + * This isn't the source map URL. + * - sourceMapURL {null|String}: URL of the source map, if there is one. + * - url {null|String}: URL of the source, if it relates to a particular URL. + * Evaled sources won't have any related URL. + * - isBlackBoxed {Boolean}: Specifying whether the source actor's 'black-boxed' flag is set. + * - extensionName {null|String}: If the source comes from an add-on, the add-on name. + */ +module.exports = async function({ targetList, targetFront, onAvailable }) { + const isBrowserToolbox = targetList.targetFront.isParentProcess; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetList.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + const threadFront = await targetFront.getFront("thread"); + + // Use a list of all notified SourceFront as we don't have a newSource event for all sources + // but we sometime get sources notified both via newSource event *and* sources() method... + // We store actor ID instead of SourceFront as it appears that multiple SourceFront for the same + // actor are created... + const sourcesActorIDCache = new Set(); + + // Forward new sources (but also existing ones, see next comment) + threadFront.on("newSource", ({ source }) => { + if (sourcesActorIDCache.has(source.actor)) { + return; + } + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceWatcher.TYPES.SOURCE; + onAvailable([source]); + }); + + // Forward already existing sources + // Note that calling `sources()` will end up emitting `newSource` event for all existing sources. + // But not in some cases, for example, when the thread is already paused. + // (And yes, it means that already existing sources can be transfered twice over the wire) + // + // Also, browser_ext_devtools_inspectedWindow_targetSwitch.js creates many top level targets, + // for which the SourceMapURLService will fetch sources. But these targets are destroyed while + // the test is running and when they are, we purge all pending requests, including this one. + // So ignore any error if this request failed on destruction. + let sources; + try { + sources = await threadFront.sources(); + } catch (e) { + if (threadFront.isDestroyed()) { + return; + } + throw e; + } + + // Note that `sources()` doesn't encapsulate SourceFront into a `source` attribute + // while `newSource` event does. + sources = sources.filter(source => { + return !sourcesActorIDCache.has(source.actor); + }); + for (const source of sources) { + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceWatcher.TYPES.SOURCE; + } + onAvailable(sources); +}; diff --git a/devtools/shared/resources/legacy-listeners/storage-utils.js b/devtools/shared/resources/legacy-listeners/storage-utils.js new file mode 100644 index 0000000000..c1b96755fd --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/storage-utils.js @@ -0,0 +1,97 @@ +/* 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/. */ + +"use strict"; + +// Filters "stores-update" response to only include events for +// the storage type we desire +function getFilteredStorageEvents(updates, storageType) { + const filteredUpdate = Object.create(null); + + // updateType will be "added", "changed", or "deleted" + for (const updateType in updates) { + if (updates[updateType][storageType]) { + if (!filteredUpdate[updateType]) { + filteredUpdate[updateType] = {}; + } + filteredUpdate[updateType][storageType] = + updates[updateType][storageType]; + } + } + + return Object.keys(filteredUpdate).length > 0 ? filteredUpdate : null; +} + +// This is a mixin that provides all shared cored between storage legacy +// listeners +function makeStorageLegacyListener(storageKey, storageType) { + return async function({ + targetList, + targetType, + targetFront, + onAvailable, + onUpdated, + onDestroyed, + }) { + if (!targetFront.isTopLevel) { + return; + } + + const storageFront = await targetFront.getFront("storage"); + const storageTypes = await storageFront.listStores(); + + // Initialization + const storage = storageTypes[storageKey]; + + // extension storage might not be available + if (!storage) { + return; + } + + storage.resourceType = storageType; + storage.resourceKey = storageKey; + // storage resources are singletons, and thus we can set their ID to their + // storage type + storage.resourceId = storageType; + onAvailable([storage]); + + // Any item in the store gets updated + storageFront.on("stores-update", response => { + response = getFilteredStorageEvents(response, storageKey); + if (!response) { + return; + } + onUpdated([ + { + resourceId: storageType, + resourceType: storageType, + resourceKey: storageKey, + changed: response.changed, + added: response.added, + deleted: response.deleted, + }, + ]); + }); + + // When a store gets cleared + storageFront.on("stores-cleared", response => { + const cleared = response[storageKey]; + + if (!cleared) { + return; + } + + onDestroyed([ + { + resourceId: storageType, + resourceType: storageType, + resourceKey: storageKey, + clearedHostsOrPaths: cleared, + }, + ]); + }); + }; +} + +module.exports = { makeStorageLegacyListener }; diff --git a/devtools/shared/resources/legacy-listeners/stylesheet.js b/devtools/shared/resources/legacy-listeners/stylesheet.js new file mode 100644 index 0000000000..d5c87ab2e9 --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/stylesheet.js @@ -0,0 +1,133 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetFront, onAvailable, onUpdated }) { + if (!targetFront.hasActor("styleSheets")) { + return; + } + + const onStyleSheetAdded = async (styleSheet, isNew, fileName) => { + const onMediaRules = styleSheet.getMediaRules(); + const resource = toResource(styleSheet, isNew, fileName); + + let previousMediaRules = []; + + function updateMediaRule(index, rule) { + onUpdated([ + { + resourceType: resource.resourceType, + resourceId: resource.resourceId, + updateType: "matches-change", + nestedResourceUpdates: [ + { + path: ["mediaRules", index], + value: rule, + }, + ], + }, + ]); + } + + function addMatchesChangeListener(mediaRules) { + for (const rule of previousMediaRules) { + rule.destroy(); + } + + mediaRules.forEach((rule, index) => { + rule.on("matches-change", matches => updateMediaRule(index, rule)); + }); + + previousMediaRules = mediaRules; + } + + styleSheet.on("style-applied", (kind, styleSheetFront, cause) => { + onUpdated([ + { + resourceType: resource.resourceType, + resourceId: resource.resourceId, + updateType: "style-applied", + event: { + cause, + kind, + }, + }, + ]); + }); + + styleSheet.on("property-change", (property, value) => { + onUpdated([ + { + resourceType: resource.resourceType, + resourceId: resource.resourceId, + updateType: "property-change", + resourceUpdates: { [property]: value }, + }, + ]); + }); + + styleSheet.on("media-rules-changed", mediaRules => { + addMatchesChangeListener(mediaRules); + onUpdated([ + { + resourceType: resource.resourceType, + resourceId: resource.resourceId, + updateType: "media-rules-changed", + resourceUpdates: { mediaRules }, + }, + ]); + }); + + try { + resource.mediaRules = await onMediaRules; + addMatchesChangeListener(resource.mediaRules); + } catch (e) { + // There are cases that the stylesheet front was destroyed already when/while calling + // methods of stylesheet. + console.warn("fetching media rules failed", e); + } + + return resource; + }; + + const styleSheetsFront = await targetFront.getFront("stylesheets"); + try { + const styleSheets = await styleSheetsFront.getStyleSheets(); + onAvailable( + await Promise.all( + styleSheets.map(styleSheet => + onStyleSheetAdded(styleSheet, false, null) + ) + ) + ); + + styleSheetsFront.on( + "stylesheet-added", + async (styleSheet, isNew, fileName) => { + onAvailable([await onStyleSheetAdded(styleSheet, isNew, fileName)]); + } + ); + } catch (e) { + // There are cases that the stylesheet front was destroyed already when/while calling + // methods of stylesheet. + // Especially, since source map url service starts to watch the stylesheet resources + // lazily, the possibility will be extended. + console.warn("fetching stylesheets failed", e); + } +}; + +function toResource(styleSheet, isNew, fileName) { + Object.assign(styleSheet, { + resourceId: styleSheet.actorID, + resourceType: ResourceWatcher.TYPES.STYLESHEET, + isNew, + fileName, + }); + return styleSheet; +} diff --git a/devtools/shared/resources/legacy-listeners/websocket.js b/devtools/shared/resources/legacy-listeners/websocket.js new file mode 100644 index 0000000000..e070287c4c --- /dev/null +++ b/devtools/shared/resources/legacy-listeners/websocket.js @@ -0,0 +1,62 @@ +/* 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/. */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +module.exports = async function({ targetFront, onAvailable }) { + if (!targetFront.hasActor("webSocket")) { + return; + } + + const webSocketFront = await targetFront.getFront("webSocket"); + webSocketFront.startListening(); + + webSocketFront.on( + "webSocketOpened", + (httpChannelId, effectiveURI, protocols, extensions) => { + const resource = toResource("webSocketOpened", { + httpChannelId, + effectiveURI, + protocols, + extensions, + }); + onAvailable([resource]); + } + ); + + webSocketFront.on( + "webSocketClosed", + (httpChannelId, wasClean, code, reason) => { + const resource = toResource("webSocketClosed", { + httpChannelId, + wasClean, + code, + reason, + }); + onAvailable([resource]); + } + ); + + webSocketFront.on("frameReceived", (httpChannelId, data) => { + const resource = toResource("frameReceived", { httpChannelId, data }); + onAvailable([resource]); + }); + + webSocketFront.on("frameSent", (httpChannelId, data) => { + const resource = toResource("frameSent", { httpChannelId, data }); + onAvailable([resource]); + }); +}; + +function toResource(wsMessageType, eventParams) { + return { + resourceType: ResourceWatcher.TYPES.WEBSOCKET, + wsMessageType, + ...eventParams, + }; +} diff --git a/devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js new file mode 100644 index 0000000000..d798b72c54 --- /dev/null +++ b/devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js @@ -0,0 +1,73 @@ +/* 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/. */ + +"use strict"; + +class LegacyProcessesWatcher { + constructor(targetList, onTargetAvailable, onTargetDestroyed) { + this.targetList = targetList; + this.rootFront = targetList.rootFront; + this.target = targetList.targetFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.descriptors = new Set(); + this._processListChanged = this._processListChanged.bind(this); + } + + async _processListChanged() { + if (this.targetList.isDestroyed()) { + return; + } + + const processes = await this.rootFront.listProcesses(); + // Process the new list to detect the ones being destroyed + // Force destroyed the descriptor as well as the target + for (const descriptor of this.descriptors) { + if (!processes.includes(descriptor)) { + // Manually call onTargetDestroyed listeners in order to + // ensure calling them *before* destroying the descriptor. + // Otherwise the descriptor will automatically destroy the target + // and may not fire the contentProcessTarget's destroy event. + const target = descriptor.getCachedTarget(); + if (target) { + this.onTargetDestroyed(target); + } + + descriptor.destroy(); + this.descriptors.delete(descriptor); + } + } + + const promises = processes + .filter(descriptor => !this.descriptors.has(descriptor)) + .map(async descriptor => { + // Add the new process descriptors to the local list + this.descriptors.add(descriptor); + const target = await descriptor.getTarget(); + if (!target) { + console.error( + "Wasn't able to retrieve the target for", + descriptor.actorID + ); + return; + } + await this.onTargetAvailable(target); + }); + + await Promise.all(promises); + } + + async listen() { + this.rootFront.on("processListChanged", this._processListChanged); + await this._processListChanged(); + } + + unlisten() { + this.rootFront.off("processListChanged", this._processListChanged); + } +} + +module.exports = { LegacyProcessesWatcher }; diff --git a/devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js new file mode 100644 index 0000000000..2578ab020d --- /dev/null +++ b/devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js @@ -0,0 +1,269 @@ +/* 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/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +const { WorkersListener } = require("devtools/client/shared/workers-listener"); + +const { + LegacyWorkersWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher"); + +class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher { + constructor(targetList, onTargetAvailable, onTargetDestroyed) { + super(targetList, onTargetAvailable, onTargetDestroyed); + this._registrations = []; + this._processTargets = new Set(); + + // We need to listen for registration changes at least in order to properly + // filter service workers by domain when debugging a local tab. + // + // A WorkerTarget instance has a url property, but it points to the url of + // the script, whereas the url property of the ServiceWorkerRegistration + // points to the URL controlled by the service worker. + // + // Historically we have been matching the service worker registration URL + // to match service workers for local tab tools (app panel & debugger). + // Maybe here we could have some more info on the actual worker. + this._workersListener = new WorkersListener(this.rootFront, { + registrationsOnly: true, + }); + + // Note that this is called much more often than when a registration + // is created or destroyed. WorkersListener notifies of anything that + // potentially impacted workers. + // I use it as a shortcut in this first patch. Listening to rootFront's + // "serviceWorkerRegistrationListChanged" should be enough to be notified + // about registrations. And if we need to also update the + // "debuggerServiceWorkerStatus" from here, then we would have to + // also listen to "registration-changed" one each registration. + this._onRegistrationListChanged = this._onRegistrationListChanged.bind( + this + ); + this._onNavigate = this._onNavigate.bind(this); + + // Flag used from the parent class to listen to process targets. + // Decision tree is complicated, keep all logic in the parent methods. + this._isServiceWorkerWatcher = true; + } + + /** + * Override from LegacyWorkersWatcher. + * + * We record all valid service worker targets (ie workers that match a service + * worker registration), but we will only notify about the ones which match + * the current domain. + */ + _recordWorkerTarget(workerTarget) { + return !!this._getRegistrationForWorkerTarget(workerTarget); + } + + // Override from LegacyWorkersWatcher. + _supportWorkerTarget(workerTarget) { + if (!workerTarget.isServiceWorker) { + return false; + } + + const registration = this._getRegistrationForWorkerTarget(workerTarget); + return registration && this._isRegistrationValidForTarget(registration); + } + + // Override from LegacyWorkersWatcher. + async listen() { + // Listen to the current target front. + this.target = this.targetList.targetFront; + + this._workersListener.addListener(this._onRegistrationListChanged); + + // Fetch the registrations before calling listen, since service workers + // might already be available and will need to be compared with the existing + // registrations. + await this._onRegistrationListChanged(); + + if (this.target.isLocalTab) { + // Note that we rely on "navigate" rather than "will-navigate" because the + // destroyed/available callbacks should be triggered after the Debugger + // has cleaned up its reducers, which happens on "will-navigate". + this.target.on("navigate", this._onNavigate); + } + + await super.listen(); + } + + // Override from LegacyWorkersWatcher. + unlisten() { + this._workersListener.removeListener(this._onRegistrationListChanged); + + if (this.target.isLocalTab) { + this.target.off("navigate", this._onNavigate); + } + + super.unlisten(); + } + + // Override from LegacyWorkersWatcher. + async _onProcessAvailable({ targetFront }) { + if (this.target.isLocalTab) { + // XXX: This has been ported straight from the current debugger + // implementation. Since pauseMatchingServiceWorkers expects an origin + // to filter matching workers, it only makes sense when we are debugging + // a tab. However in theory, parent process debugging could pause all + // service workers without matching anything. + const origin = new URL(this.target.url).origin; + try { + // To support early breakpoint we need to setup the + // `pauseMatchingServiceWorkers` mechanism in each process. + await targetFront.pauseMatchingServiceWorkers({ origin }); + } catch (e) { + if (targetFront.actorID) { + throw e; + } else { + console.warn( + "Process target destroyed while calling pauseMatchingServiceWorkers" + ); + } + } + } + + this._processTargets.add(targetFront); + return super._onProcessAvailable({ targetFront }); + } + + _shouldDestroyTargetsOnNavigation() { + return !!this.targetList.destroyServiceWorkersOnNavigation; + } + + _onProcessDestroyed({ targetFront }) { + this._processTargets.delete(targetFront); + return super._onProcessDestroyed({ targetFront }); + } + + _onNavigate() { + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + const shouldDestroy = this._shouldDestroyTargetsOnNavigation(); + + for (const target of allServiceWorkerTargets) { + const isRegisteredBefore = this.targetList.isTargetRegistered(target); + if (shouldDestroy && isRegisteredBefore) { + this.onTargetDestroyed(target); + } + + // Note: we call isTargetRegistered again because calls to + // onTargetDestroyed might have modified the list of registered targets. + const isRegisteredAfter = this.targetList.isTargetRegistered(target); + const isValidTarget = this._supportWorkerTarget(target); + if (isValidTarget && !isRegisteredAfter) { + // If the target is still valid for the current top target, call + // onTargetAvailable as well. + this.onTargetAvailable(target); + } + } + } + + async _onRegistrationListChanged() { + if (this.targetList.isDestroyed()) { + return; + } + + await this._updateRegistrations(); + + // Everything after this point is not strictly necessary for sw support + // in the target list, but it makes the behavior closer to the previous + // listAllWorkers/WorkersListener pair. + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + for (const target of allServiceWorkerTargets) { + const hasRegistration = this._getRegistrationForWorkerTarget(target); + if (!hasRegistration) { + // XXX: At this point the worker target is not really destroyed, but + // historically, listAllWorkers* APIs stopped returning worker targets + // if worker registrations are no longer available. + if (this.targetList.isTargetRegistered(target)) { + // Only emit onTargetDestroyed if it wasn't already done by + // onNavigate (ie the target is still tracked by TargetList) + this.onTargetDestroyed(target); + } + // Here we only care about service workers which no longer match *any* + // registration. The worker will be completely destroyed soon, remove + // it from the legacy worker watcher internal targetsByProcess Maps. + this._removeTargetReferences(target); + } + } + } + + // Delete the provided worker target from the internal targetsByProcess Maps. + _removeTargetReferences(target) { + const allProcessTargets = this._getProcessTargets().filter(t => + this.targetsByProcess.get(t) + ); + + for (const processTarget of allProcessTargets) { + this.targetsByProcess.get(processTarget).delete(target); + } + } + + async _updateRegistrations() { + const { + registrations, + } = await this.rootFront.listServiceWorkerRegistrations(); + + this._registrations = registrations; + } + + _getRegistrationForWorkerTarget(workerTarget) { + return this._registrations.find(r => { + return ( + r.evaluatingWorker?.id === workerTarget.id || + r.activeWorker?.id === workerTarget.id || + r.installingWorker?.id === workerTarget.id || + r.waitingWorker?.id === workerTarget.id + ); + }); + } + + _getProcessTargets() { + return [...this._processTargets]; + } + + // Flatten all service worker targets in all processes. + _getAllServiceWorkerTargets() { + const allProcessTargets = this._getProcessTargets().filter(target => + this.targetsByProcess.get(target) + ); + + const serviceWorkerTargets = []; + for (const target of allProcessTargets) { + serviceWorkerTargets.push(...this.targetsByProcess.get(target)); + } + return serviceWorkerTargets; + } + + // Check if the registration is relevant for the current target, ie + // corresponds to the same domain. + _isRegistrationValidForTarget(registration) { + if (this.target.isParentProcess) { + // All registrations are valid for main process debugging. + return true; + } + + if (!this.target.isLocalTab) { + // No support for service worker targets outside of main process & local + // tab debugging. + return false; + } + + // For local tabs, we match ServiceWorkerRegistrations and the target + // if they share the same hostname for their "url" properties. + const targetDomain = new URL(this.target.url).hostname; + try { + const registrationDomain = new URL(registration.url).hostname; + return registrationDomain === targetDomain; + } catch (e) { + // XXX: Some registrations have an empty URL. + return false; + } + } +} + +module.exports = { LegacyServiceWorkersWatcher }; diff --git a/devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js new file mode 100644 index 0000000000..c1fa7c5130 --- /dev/null +++ b/devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js @@ -0,0 +1,21 @@ +/* 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/. */ + +"use strict"; + +const { + LegacyWorkersWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher"); + +class LegacySharedWorkersWatcher extends LegacyWorkersWatcher { + // Flag used from the parent class to listen to process targets. + // Decision tree is complicated, keep all logic in the parent methods. + _isSharedWorkerWatcher = true; + + _supportWorkerTarget(workerTarget) { + return workerTarget.isSharedWorker; + } +} + +module.exports = { LegacySharedWorkersWatcher }; diff --git a/devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher.js new file mode 100644 index 0000000000..0b0b07a897 --- /dev/null +++ b/devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher.js @@ -0,0 +1,225 @@ +/* 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/. */ + +"use strict"; + +loader.lazyRequireGetter( + this, + "TargetList", + "devtools/shared/resources/target-list", + true +); + +const { + LegacyProcessesWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher"); + +class LegacyWorkersWatcher { + constructor(targetList, onTargetAvailable, onTargetDestroyed) { + this.targetList = targetList; + this.rootFront = targetList.rootFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.targetsByProcess = new WeakMap(); + this.targetsListeners = new WeakMap(); + + this._onProcessAvailable = this._onProcessAvailable.bind(this); + this._onProcessDestroyed = this._onProcessDestroyed.bind(this); + } + + async _onProcessAvailable({ targetFront }) { + this.targetsByProcess.set(targetFront, new Set()); + // Listen for worker which will be created later + const listener = this._workerListChanged.bind(this, targetFront); + this.targetsListeners.set(targetFront, listener); + + // If this is the browser toolbox, we have to listen from the RootFront + // (see comment in _workerListChanged) + const front = targetFront.isParentProcess ? this.rootFront : targetFront; + front.on("workerListChanged", listener); + + // We also need to process the already existing workers + await this._workerListChanged(targetFront); + } + + async _onProcessDestroyed({ targetFront }) { + const existingTargets = this.targetsByProcess.get(targetFront); + + // Process the new list to detect the ones being destroyed + // Force destroying the targets + for (const target of existingTargets) { + this.onTargetDestroyed(target); + + target.destroy(); + existingTargets.delete(target); + } + this.targetsByProcess.delete(targetFront); + this.targetsListeners.delete(targetFront); + } + + _supportWorkerTarget(workerTarget) { + // subprocess workers are ignored because they take several seconds to + // attach to when opening the browser toolbox. See bug 1594597. + // When attaching we get the following error: + // JavaScript error: resource://devtools/server/startup/worker.js, + // line 37: NetworkError: WorkerDebuggerGlobalScope.loadSubScript: Failed to load worker script at resource://devtools/shared/worker/loader.js (nsresult = 0x805e0006) + return ( + workerTarget.isDedicatedWorker && + !workerTarget.url.startsWith( + "resource://gre/modules/subprocess/subprocess_worker" + ) + ); + } + + async _workerListChanged(targetFront) { + // If we're in the Browser Toolbox, query workers from the Root Front instead of the + // ParentProcessTarget as the ParentProcess Target filters out the workers to only + // show the one from the top level window, whereas we expect the one from all the + // windows, and also the window-less ones. + // TODO: For Content Toolbox, expose SW of the page, maybe optionally? + const front = targetFront.isParentProcess ? this.rootFront : targetFront; + if (!front || front.isDestroyed() || this.targetList.isDestroyed()) { + return; + } + + const { workers } = await front.listWorkers(); + + // Fetch the list of already existing worker targets for this process target front. + const existingTargets = this.targetsByProcess.get(targetFront); + if (!existingTargets) { + // unlisten was called while processing the workerListChanged callback. + return; + } + + // Process the new list to detect the ones being destroyed + // Force destroying the targets + for (const target of existingTargets) { + if (!workers.includes(target)) { + this.onTargetDestroyed(target); + + target.destroy(); + existingTargets.delete(target); + } + } + + const promises = workers.map(workerTarget => + this._processNewWorkerTarget(workerTarget, existingTargets) + ); + await Promise.all(promises); + } + + // This is overloaded for Service Workers, which records all SW targets, + // but only notify about a subset of them. + _recordWorkerTarget(workerTarget) { + return this._supportWorkerTarget(workerTarget); + } + + async _processNewWorkerTarget(workerTarget, existingTargets) { + if ( + !this._recordWorkerTarget(workerTarget) || + existingTargets.has(workerTarget) || + this.targetList.isDestroyed() + ) { + return; + } + + // Add the new worker targets to the local list + existingTargets.add(workerTarget); + + if (this._supportWorkerTarget(workerTarget)) { + await this.onTargetAvailable(workerTarget); + } + } + + async listen() { + // Listen to the current target front. + this.target = this.targetList.targetFront; + + if (this.target.isParentProcess) { + await this.targetList.watchTargets( + [TargetList.TYPES.PROCESS], + this._onProcessAvailable, + this._onProcessDestroyed + ); + + // The ParentProcessTarget front is considered to be a FRAME instead of a PROCESS. + // So process it manually here. + await this._onProcessAvailable({ targetFront: this.target }); + return; + } + + if (this._isSharedWorkerWatcher) { + // Here we're not in the browser toolbox, and SharedWorker targets are not supported + // in regular toolbox (See Bug 1607778) + return; + } + + if (this._isServiceWorkerWatcher) { + this._legacyProcessesWatcher = new LegacyProcessesWatcher( + this.targetList, + async targetFront => { + // Service workers only live in content processes. + if (!targetFront.isParentProcess) { + await this._onProcessAvailable({ targetFront }); + } + }, + targetFront => { + if (!targetFront.isParentProcess) { + this._onProcessDestroyed({ targetFront }); + } + } + ); + await this._legacyProcessesWatcher.listen(); + return; + } + + // Here, we're handling Dedicated Workers in content toolbox. + this.targetsByProcess.set(this.target, new Set()); + this._workerListChangedListener = this._workerListChanged.bind( + this, + this.target + ); + this.target.on("workerListChanged", this._workerListChangedListener); + await this._workerListChanged(this.target); + } + + _getProcessTargets() { + return this.targetList.getAllTargets([TargetList.TYPES.PROCESS]); + } + + unlisten() { + // Stop listening for new process targets. + if (this.target.isParentProcess) { + this.targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + this._onProcessAvailable, + this._onProcessDestroyed + ); + } else if (this._isServiceWorkerWatcher) { + this._legacyProcessesWatcher.unlisten(); + } + + // Cleanup the targetsByProcess/targetsListeners maps, and unsubscribe from + // all targetFronts. Process target fronts are either stored locally when + // watching service workers for the content toolbox, or can be retrieved via + // the TargetList API otherwise (see _getProcessTargets implementations). + if (this.target.isParentProcess || this._isServiceWorkerWatcher) { + for (const targetFront of this._getProcessTargets()) { + const listener = this.targetsListeners.get(targetFront); + targetFront.off("workerListChanged", listener); + this.targetsByProcess.delete(targetFront); + this.targetsListeners.delete(targetFront); + } + } else { + this.target.off("workerListChanged", this._workerListChangedListener); + delete this._workerListChangedListener; + this.targetsByProcess.delete(this.target); + this.targetsListeners.delete(this.target); + } + } +} + +module.exports = { LegacyWorkersWatcher }; diff --git a/devtools/shared/resources/legacy-target-watchers/moz.build b/devtools/shared/resources/legacy-target-watchers/moz.build new file mode 100644 index 0000000000..60fdd7ec22 --- /dev/null +++ b/devtools/shared/resources/legacy-target-watchers/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "legacy-processes-watcher.js", + "legacy-serviceworkers-watcher.js", + "legacy-sharedworkers-watcher.js", + "legacy-workers-watcher.js", +) diff --git a/devtools/shared/resources/moz.build b/devtools/shared/resources/moz.build new file mode 100644 index 0000000000..5e709be68d --- /dev/null +++ b/devtools/shared/resources/moz.build @@ -0,0 +1,17 @@ +# 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/. + +DIRS += [ + "legacy-listeners", + "legacy-target-watchers", + "transformers", +] + +DevToolsModules( + "resource-watcher.js", + "target-list.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"] diff --git a/devtools/shared/resources/resource-watcher.js b/devtools/shared/resources/resource-watcher.js new file mode 100644 index 0000000000..02c7f07dca --- /dev/null +++ b/devtools/shared/resources/resource-watcher.js @@ -0,0 +1,925 @@ +/* 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/. */ + +"use strict"; + +const { throttle } = require("devtools/shared/throttle"); + +class ResourceWatcher { + /** + * This class helps retrieving existing and listening to resources. + * A resource is something that: + * - the target you are debugging exposes + * - can be created as early as the process/worker/page starts loading + * - can already exist, or will be created later on + * - doesn't require any user data to be fetched, only a type/category + * + * @param {TargetList} targetList + * A TargetList instance, which helps communicating to the backend + * in order to iterate and listen over the requested resources. + */ + + constructor(targetList) { + this.targetList = targetList; + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceDestroyed = this._onResourceDestroyed.bind(this); + + // Array of all the currently registered watchers, which contains object with attributes: + // - {String} resources: list of all resource watched by this one watcher + // - {Function} onAvailable: watcher's function to call when a new resource is available + // - {Function} onUpdated: watcher's function to call when a resource has been updated + // - {Function} onDestroyed: watcher's function to call when a resource is destroyed + this._watchers = []; + + // Cache for all resources by the order that the resource was taken. + this._cache = []; + this._listenerCount = new Map(); + + // WeakMap used to avoid starting a legacy listener twice for the same + // target + resource-type pair. Legacy listener creation can be subject to + // race conditions. + // Maps a target front to an array of resource types. + this._existingLegacyListeners = new WeakMap(); + + this._notifyWatchers = this._notifyWatchers.bind(this); + this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100); + } + + get watcherFront() { + return this.targetList.watcherFront; + } + + /** + * Return all specified resources cached in this watcher. + * + * @param {String} resourceType + * @return {Array} resources cached in this watcher + */ + getAllResources(resourceType) { + return this._cache.filter(r => r.resourceType === resourceType); + } + + /** + * Return the specified resource cached in this watcher. + * + * @param {String} resourceType + * @param {String} resourceId + * @return {Object} resource cached in this watcher + */ + getResourceById(resourceType, resourceId) { + return this._cache.find( + r => r.resourceType === resourceType && r.resourceId === resourceId + ); + } + + /** + * Request to start retrieving all already existing instances of given + * type of resources and also start watching for the one to be created after. + * + * @param {Array:string} resources + * List of all resources which should be fetched and observed. + * @param {Object} options + * - {Function} onAvailable: This attribute is mandatory. + * Function which will be called once per existing + * resource and each time a resource is created. + * - {Function} onUpdated: This attribute is optional. + * Function which will be called each time a resource, + * previously notified via onAvailable is updated. + * - {Function} onDestroyed: This attribute is optional. + * Function which will be called each time a resource in + * the remote target is destroyed. + * - {boolean} ignoreExistingResources: + * This attribute is optional. Default value is false. + * If set to true, onAvailable won't be called with + * existing resources. + */ + async watchResources(resources, options) { + const { + onAvailable, + onUpdated, + onDestroyed, + ignoreExistingResources = false, + } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceWatcher.watchResources expects an onAvailable function as argument" + ); + } + + // Bug 1675763: Watcher actor is not available in all situations yet. + if (!this._listenerRegistered && this.watcherFront) { + this._listenerRegistered = true; + // Resources watched from the parent process will be emitted on the Watcher Actor. + // So that we also have to listen for this event on it, in addition to all targets. + this.watcherFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { + watcherFront: this.watcherFront, + }) + ); + this.watcherFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { watcherFront: this.watcherFront }) + ); + this.watcherFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { + watcherFront: this.watcherFront, + }) + ); + } + + // First ensuring enabling listening to targets. + // This will call onTargetAvailable for all already existing targets, + // as well as for the one created later. + // Do this *before* calling _startListening in order to register + // "resource-available" listener before requesting for the resources in _startListening. + await this._watchAllTargets(); + + for (const resource of resources) { + // If we are registering the first listener, so start listening from the server about + // this one resource. + if (!this._hasListenerForResource(resource)) { + await this._startListening(resource); + } + } + + // The resource cache is immediately filled when receiving the sources, but they are + // emitted with a delay due to throttling. Since the cache can contain resources that + // will soon be emitted, we have to flush it before adding the new listeners. + // Otherwise forwardCacheResources might emit resources that will also be emitted by + // the next `_notifyWatchers` call done when calling `_startListening`, which will pull the + // "already existing" resources. + this._notifyWatchers(); + + // Register the watcher just after calling _startListening in order to avoid it being called + // for already existing resources, which will optionally be notified via _forwardCachedResources + this._watchers.push({ + resources, + onAvailable, + onUpdated, + onDestroyed, + pendingEvents: [], + }); + + if (!ignoreExistingResources) { + await this._forwardCachedResources(resources, onAvailable); + } + } + + /** + * Stop watching for given type of resources. + * See `watchResources` for the arguments as both methods receive the same. + * Note that `onUpdated` and `onDestroyed` attributes of `options` aren't used here. + * Only `onAvailable` attribute is looked up and we unregister all the other registered callbacks + * when a matching available callback is found. + */ + unwatchResources(resources, options) { + const { onAvailable } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceWatcher.unwatchResources expects an onAvailable function as argument" + ); + } + + const watchedResources = []; + for (const resource of resources) { + if (this._hasListenerForResource(resource)) { + watchedResources.push(resource); + } + } + // Unregister the callbacks from the _watchers registry + for (const watcherEntry of this._watchers) { + // onAvailable is the only mandatory argument which ends up being used to match + // the right watcher entry. + if (watcherEntry.onAvailable == onAvailable) { + // Remove all resources that we stop watching. We may still watch for some others. + watcherEntry.resources = watcherEntry.resources.filter(resourceType => { + return !resources.includes(resourceType); + }); + } + } + this._watchers = this._watchers.filter(entry => { + // Remove entries entirely if it isn't watching for any resource type + return entry.resources.length > 0; + }); + + // Stop listening to all resources that no longer have any watcher callback + for (const resource of watchedResources) { + if (!this._hasListenerForResource(resource)) { + this._stopListening(resource); + } + } + + // Stop watching for targets if we removed the last listener. + let listeners = 0; + for (const count of this._listenerCount.values()) { + listeners += count; + } + if (listeners <= 0) { + this._unwatchAllTargets(); + } + } + + /** + * Start watching for all already existing and future targets. + * + * We are using ALL_TYPES, but this won't force listening to all types. + * It will only listen for types which are defined by `TargetList.startListening`. + */ + async _watchAllTargets() { + if (!this._watchTargetsPromise) { + this._watchTargetsPromise = this.targetList.watchTargets( + this.targetList.ALL_TYPES, + this._onTargetAvailable, + this._onTargetDestroyed + ); + } + return this._watchTargetsPromise; + } + + _unwatchAllTargets() { + if (!this._watchTargetsPromise) { + return; + } + this._watchTargetsPromise = null; + this.targetList.unwatchTargets( + this.targetList.ALL_TYPES, + this._onTargetAvailable, + this._onTargetDestroyed + ); + } + + /** + * Method called by the TargetList for each already existing or target which has just been created. + * + * @param {Front} targetFront + * The Front of the target that is available. + * This Front inherits from TargetMixin and is typically + * composed of a BrowsingContextTargetFront or ContentProcessTargetFront. + */ + async _onTargetAvailable({ targetFront, isTargetSwitching }) { + const resources = []; + if (isTargetSwitching) { + this._onWillNavigate(targetFront); + // WatcherActor currently only watches additional frame targets and + // explicitely ignores top level one that may be created when navigating + // to a new process. + // In order to keep working resources that are being watched via the + // Watcher actor, we have to unregister and re-register the resource + // types. This will force calling `Resources.watchResources` on the new top + // level target. + for (const resourceType of Object.values(ResourceWatcher.TYPES)) { + // ...which has at least one listener... + if (!this._listenerCount.get(resourceType)) { + continue; + } + await this._stopListening(resourceType, { bypassListenerCount: true }); + resources.push(resourceType); + } + } + + if (targetFront.isDestroyed()) { + return; + } + + targetFront.on("will-navigate", () => this._onWillNavigate(targetFront)); + + // If we are target switching, we already stop & start listening to all the + // currently monitored resources. + if (!isTargetSwitching) { + // For each resource type... + for (const resourceType of Object.values(ResourceWatcher.TYPES)) { + // ...which has at least one listener... + if (!this._listenerCount.get(resourceType)) { + continue; + } + // ...request existing resource and new one to come from this one target + // *but* only do that for backward compat, where we don't have the watcher API + // (See bug 1626647) + await this._watchResourcesForTarget(targetFront, resourceType); + } + } + + // Compared to the TargetList and Watcher.watchTargets, + // We do call Watcher.watchResources, but the events are fired on the target. + // That's because the Watcher runs in the parent process/main thread, while resources + // are available from the target's process/thread. + targetFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { targetFront }) + ); + targetFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { targetFront }) + ); + targetFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { targetFront }) + ); + + if (isTargetSwitching) { + for (const resourceType of resources) { + await this._startListening(resourceType, { bypassListenerCount: true }); + } + } + } + + /** + * Method called by the TargetList when a target has just been destroyed + * See _onTargetAvailable for arguments, they are the same. + */ + _onTargetDestroyed({ targetFront }) { + // Clear the map of legacy listeners for this target. + this._existingLegacyListeners.set(targetFront, []); + + //TODO: Is there a point in doing anything else? + // + // We could remove the available/destroyed event, but as the target is destroyed + // its listeners will be destroyed anyway. + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * whenever an already existing resource is being listed or when a new one + * has been created. + * + * @param {Object} source + * A dictionary object with only one of these two attributes: + * - targetFront: a Target Front, if the resource is watched from the target process or thread + * - watcherFront: a Watcher Front, if the resource is watched from the parent process + * @param {Array<json/Front>} resources + * Depending on the resource Type, it can be an Array composed of either JSON objects or Fronts, + * which describes the resource. + */ + async _onResourceAvailable({ targetFront, watcherFront }, resources) { + for (let resource of resources) { + const { resourceType } = resource; + + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(resource); + if (!targetFront) { + continue; + } + } + + // Put the targetFront on the resource for easy retrieval. + // (Resources from the legacy listeners may already have the attribute set) + if (!resource.targetFront) { + resource.targetFront = targetFront; + } + + if (ResourceTransformers[resourceType]) { + resource = ResourceTransformers[resourceType]({ + resource, + targetList: this.targetList, + targetFront, + watcherFront: this.watcherFront, + }); + } + + this._queueResourceEvent("available", resourceType, resource); + + this._cache.push(resource); + } + this._throttledNotifyWatchers(); + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * Called everytime a resource is updated in the remote target. + * + * @param {Object} source + * Please see _onResourceAvailable for this parameter. + * @param {Array<Object>} updates + * Depending on the listener. + * + * Among the element in the array, the following attributes are given special handling. + * - resourceType {String}: + * The type of resource to be updated. + * - resourceId {String}: + * The id of resource to be updated. + * - resourceUpdates {Object}: + * If resourceUpdates is in the element, a cached resource specified by resourceType + * and resourceId is updated by Object.assign(cachedResource, resourceUpdates). + * - nestedResourceUpdates {Object}: + * If `nestedResourceUpdates` is passed, update one nested attribute with a new value + * This allows updating one attribute of an object stored in a resource's attribute, + * as well as adding new elements to arrays. + * `path` is an array mentioning all nested attribute to walk through. + * `value` is the new nested attribute value to set. + * + * And also, the element is passed to the listener as it is as โupdateโ object. + * So if we don't want to update a cached resource but have information want to + * pass on to the listener, can pass it on using attributes other than the ones + * listed above. + * For example, if the element consists of like + * "{ resourceType:โฆ resourceId:โฆ, testValue: โtestโ, }โ, + * the listener can receive the value as follows. + * + * onResourceUpdate({ update }) { + * console.log(update.testValue); // โtestโ should be displayed + * } + */ + async _onResourceUpdated({ targetFront, watcherFront }, updates) { + for (const update of updates) { + const { + resourceType, + resourceId, + resourceUpdates, + nestedResourceUpdates, + } = update; + + if (!resourceId) { + console.warn(`Expected resource ${resourceType} to have a resourceId`); + } + + const existingResource = this._cache.find( + cachedResource => + cachedResource.resourceType === resourceType && + cachedResource.resourceId === resourceId + ); + + if (!existingResource) { + continue; + } + + if (resourceUpdates) { + Object.assign(existingResource, resourceUpdates); + } + + if (nestedResourceUpdates) { + for (const { path, value } of nestedResourceUpdates) { + let target = existingResource; + + for (let i = 0; i < path.length - 1; i++) { + target = target[path[i]]; + } + + target[path[path.length - 1]] = value; + } + } + this._queueResourceEvent("updated", resourceType, { + resource: existingResource, + update, + }); + } + this._throttledNotifyWatchers(); + } + + /** + * Called everytime a resource is destroyed in the remote target. + * See _onResourceAvailable for the argument description. + */ + async _onResourceDestroyed({ targetFront, watcherFront }, resources) { + for (const resource of resources) { + const { resourceType, resourceId } = resource; + + let index = -1; + if (resourceId) { + index = this._cache.findIndex( + cachedResource => + cachedResource.resourceType == resourceType && + cachedResource.resourceId == resourceId + ); + } else { + index = this._cache.indexOf(resource); + } + if (index >= 0) { + this._cache.splice(index, 1); + } else { + console.warn( + `Resource ${resourceId || ""} of ${resourceType} was not found.` + ); + } + + this._queueResourceEvent("destroyed", resourceType, resource); + } + this._throttledNotifyWatchers(); + } + + /** + * Check if there is at least one listener registered for the given resource type. + * + * @param {String} resourceType + * Watched resource type + */ + _hasListenerForResource(resourceType) { + return this._watchers.some(({ resources }) => { + return resources.includes(resourceType); + }); + } + + _queueResourceEvent(callbackType, resourceType, update) { + for (const { resources, pendingEvents } of this._watchers) { + // This watcher doesn't listen to this type of resource + if (!resources.includes(resourceType)) { + continue; + } + // If we receive a new event of the same type, accumulate the new update in the last event + if (pendingEvents.length > 0) { + const lastEvent = pendingEvents[pendingEvents.length - 1]; + if (lastEvent.callbackType == callbackType) { + lastEvent.updates.push(update); + continue; + } + } + // Otherwise, pile up a new event, which will force calling watcher + // callback a new time + pendingEvents.push({ + callbackType, + updates: [update], + }); + } + } + + /** + * Flush the pending event and notify all the currently registered watchers + * about all the available, updated and destroyed events that have been accumulated in + * `_watchers`'s `pendingEvents` arrays. + */ + _notifyWatchers() { + for (const watcherEntry of this._watchers) { + const { + onAvailable, + onUpdated, + onDestroyed, + pendingEvents, + } = watcherEntry; + // Immediately clear the buffer in order to avoid possible races, where an event listener + // would end up somehow adding a new throttled resource + watcherEntry.pendingEvents = []; + + for (const { callbackType, updates } of pendingEvents) { + try { + if (callbackType == "available") { + onAvailable(updates); + } else if (callbackType == "updated" && onUpdated) { + onUpdated(updates); + } else if (callbackType == "destroyed" && onDestroyed) { + onDestroyed(updates); + } + } catch (e) { + console.error( + "Exception while calling a ResourceWatcher", + callbackType, + "callback", + ":", + e + ); + } + } + } + } + + // Compute the target front if the resource comes from the Watcher Actor. + // (`targetFront` will be null as the watcher is in the parent process + // and targets are in distinct processes) + _getTargetForWatcherResource(resource) { + const { browsingContextID, resourceType } = resource; + + // Resource emitted from the Watcher Actor should all have a + // browsingContextID attribute + if (!browsingContextID) { + console.error( + `Resource of ${resourceType} is missing a browsingContextID attribute` + ); + return null; + } + return this.watcherFront.getBrowsingContextTarget(browsingContextID); + } + + _onWillNavigate(targetFront) { + if (targetFront.isTopLevel) { + this._cache = []; + return; + } + + this._cache = this._cache.filter( + cachedResource => cachedResource.targetFront !== targetFront + ); + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method. + * + * @return {Boolean} True, if the server supports this type. + */ + hasResourceWatcherSupport(resourceType) { + return this.watcherFront?.traits?.resources?.[resourceType]; + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method, and that, for a specific + * target. + * + * @return {Boolean} True, if the server supports this type. + */ + _hasResourceWatcherSupportForTarget(resourceType, targetFront) { + // First check if the watcher supports this target type. + // If it doesn't, no resource type can be listened via the Watcher actor for this target. + if (!this.targetList.hasTargetWatcherSupport(targetFront.targetType)) { + return false; + } + + return this.hasResourceWatcherSupport(resourceType); + } + + /** + * Start listening for a given type of resource. + * For backward compatibility code, we register the legacy listeners on + * each individual target + * + * @param {String} resourceType + * One string of ResourceWatcher.TYPES, which designates the types of resources + * to be listened. + * @param {Object} + * - {Boolean} bypassListenerCount + * Pass true to avoid checking/updating the listenersCount map. + * Exclusively used when target switching, to stop & start listening + * to all resources. + */ + async _startListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + let listeners = this._listenerCount.get(resourceType) || 0; + listeners++; + this._listenerCount.set(resourceType, listeners); + + if (listeners > 1) { + return; + } + } + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceWatcherSupport(resourceType)) { + await this.watcherFront.watchResources([resourceType]); + + // Bug 1678385: In order to support watching for JS Source resource + // for service workers and parent process workers, which aren't supported yet + // by the watcher actor, we do not bail out here and allow to execute + // the legacy listener for these targets. + // Once bug 1608848 is fixed, we can remove this and always return. + // If this isn't fixed soon, we may add other resources we want to see + // being fetched from these targets. + const shouldRunLegacyListeners = + resourceType == ResourceWatcher.TYPES.SOURCE; + if (!shouldRunLegacyListeners) { + return; + } + } + // Otherwise, fallback on backward compat mode and use LegacyListeners. + + // If this is the first listener for this type of resource, + // we should go through all the existing targets as onTargetAvailable + // has already been called for these existing targets. + const promises = []; + const targets = this.targetList.getAllTargets(this.targetList.ALL_TYPES); + for (const target of targets) { + promises.push(this._watchResourcesForTarget(target, resourceType)); + } + await Promise.all(promises); + } + + async _forwardCachedResources(resourceTypes, onAvailable) { + const cachedResources = this._cache.filter(resource => + resourceTypes.includes(resource.resourceType) + ); + if (cachedResources.length > 0) { + await onAvailable(cachedResources); + } + } + + /** + * Call backward compatibility code from `LegacyListeners` in order to listen for a given + * type of resource from a given target. + */ + _watchResourcesForTarget(targetFront, resourceType) { + if (this._hasResourceWatcherSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to start legacy listeners. + return Promise.resolve(); + } + + if (targetFront.isDestroyed()) { + return Promise.resolve(); + } + + const onAvailable = this._onResourceAvailable.bind(this, { targetFront }); + const onUpdated = this._onResourceUpdated.bind(this, { targetFront }); + const onDestroyed = this._onResourceDestroyed.bind(this, { targetFront }); + + if (!(resourceType in LegacyListeners)) { + throw new Error(`Missing legacy listener for ${resourceType}`); + } + + const legacyListeners = + this._existingLegacyListeners.get(targetFront) || []; + if (legacyListeners.includes(resourceType)) { + console.error( + `Already started legacy listener for ${resourceType} on ${targetFront.actorID}` + ); + return Promise.resolve(); + } + this._existingLegacyListeners.set( + targetFront, + legacyListeners.concat(resourceType) + ); + + return LegacyListeners[resourceType]({ + targetList: this.targetList, + targetFront, + onAvailable, + onDestroyed, + onUpdated, + }); + } + + /** + * Reverse of _startListening. Stop listening for a given type of resource. + * For backward compatibility, we unregister from each individual target. + * + * See _startListening for parameters description. + */ + _stopListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + let listeners = this._listenerCount.get(resourceType); + if (!listeners || listeners <= 0) { + throw new Error( + `Stopped listening for resource '${resourceType}' that isn't being listened to` + ); + } + listeners--; + this._listenerCount.set(resourceType, listeners); + if (listeners > 0) { + return; + } + } + + // Clear the cached resources of the type. + this._cache = this._cache.filter( + cachedResource => cachedResource.resourceType !== resourceType + ); + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceWatcherSupport(resourceType)) { + if (!this.watcherFront.isDestroyed()) { + this.watcherFront.unwatchResources([resourceType]); + } + + // See comment in `_startListening` + const shouldRunLegacyListeners = + resourceType == ResourceWatcher.TYPES.SOURCE; + if (!shouldRunLegacyListeners) { + return; + } + } + // Otherwise, fallback on backward compat mode and use LegacyListeners. + + // If this was the last listener, we should stop watching these events from the actors + // and the actors should stop watching things from the platform + const targets = this.targetList.getAllTargets(this.targetList.ALL_TYPES); + for (const target of targets) { + this._unwatchResourcesForTarget(target, resourceType); + } + } + + /** + * Backward compatibility code, reverse of _watchResourcesForTarget. + */ + _unwatchResourcesForTarget(targetFront, resourceType) { + if (this._hasResourceWatcherSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to stop legacy listeners. + } + // Is there really a point in: + // - unregistering `onAvailable` RDP event callbacks from target-scoped actors? + // - calling `stopListeners()` as we are most likely closing the toolbox and destroying everything? + // + // It is important to keep this method synchronous and do as less as possible + // in the case of toolbox destroy. + // + // We are aware of one case where that might be useful. + // When a panel is disabled via the options panel, after it has been opened. + // Would that justify doing this? Is there another usecase? + + // XXX: This is most likely only needed to avoid growing the Map infinitely. + // Unless in the "disabled panel" use case mentioned in the comment above, + // we should not see the same target actorID again. + const listeners = this._existingLegacyListeners.get(targetFront); + if (listeners && listeners.includes(resourceType)) { + const remainingListeners = listeners.filter(l => l !== resourceType); + this._existingLegacyListeners.set(targetFront, remainingListeners); + } + } +} + +ResourceWatcher.TYPES = ResourceWatcher.prototype.TYPES = { + CONSOLE_MESSAGE: "console-message", + CSS_CHANGE: "css-change", + CSS_MESSAGE: "css-message", + ERROR_MESSAGE: "error-message", + PLATFORM_MESSAGE: "platform-message", + DOCUMENT_EVENT: "document-event", + ROOT_NODE: "root-node", + STYLESHEET: "stylesheet", + NETWORK_EVENT: "network-event", + WEBSOCKET: "websocket", + COOKIE: "cookie", + LOCAL_STORAGE: "local-storage", + SESSION_STORAGE: "session-storage", + CACHE_STORAGE: "Cache", + EXTENSION_STORAGE: "extension-storage", + INDEXED_DB: "indexed-db", + NETWORK_EVENT_STACKTRACE: "network-event-stacktrace", + SOURCE: "source", +}; +module.exports = { ResourceWatcher, TYPES: ResourceWatcher.TYPES }; + +// Backward compat code for each type of resource. +// Each section added here should eventually be removed once the equivalent server +// code is implement in Firefox, in its release channel. +const LegacyListeners = { + [ResourceWatcher.TYPES + .CONSOLE_MESSAGE]: require("devtools/shared/resources/legacy-listeners/console-messages"), + [ResourceWatcher.TYPES + .CSS_CHANGE]: require("devtools/shared/resources/legacy-listeners/css-changes"), + [ResourceWatcher.TYPES + .CSS_MESSAGE]: require("devtools/shared/resources/legacy-listeners/css-messages"), + [ResourceWatcher.TYPES + .ERROR_MESSAGE]: require("devtools/shared/resources/legacy-listeners/error-messages"), + [ResourceWatcher.TYPES + .PLATFORM_MESSAGE]: require("devtools/shared/resources/legacy-listeners/platform-messages"), + async [ResourceWatcher.TYPES.DOCUMENT_EVENT]({ + targetList, + targetFront, + onAvailable, + }) { + // DocumentEventsListener of webconsole handles only top level document. + if (!targetFront.isTopLevel) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + webConsoleFront.on("documentEvent", event => { + event.resourceType = ResourceWatcher.TYPES.DOCUMENT_EVENT; + onAvailable([event]); + }); + await webConsoleFront.startListeners(["DocumentEvents"]); + }, + [ResourceWatcher.TYPES + .ROOT_NODE]: require("devtools/shared/resources/legacy-listeners/root-node"), + [ResourceWatcher.TYPES + .STYLESHEET]: require("devtools/shared/resources/legacy-listeners/stylesheet"), + [ResourceWatcher.TYPES + .NETWORK_EVENT]: require("devtools/shared/resources/legacy-listeners/network-events"), + [ResourceWatcher.TYPES + .WEBSOCKET]: require("devtools/shared/resources/legacy-listeners/websocket"), + [ResourceWatcher.TYPES + .COOKIE]: require("devtools/shared/resources/legacy-listeners/cookie"), + [ResourceWatcher.TYPES + .LOCAL_STORAGE]: require("devtools/shared/resources/legacy-listeners/local-storage"), + [ResourceWatcher.TYPES + .SESSION_STORAGE]: require("devtools/shared/resources/legacy-listeners/session-storage"), + [ResourceWatcher.TYPES + .CACHE_STORAGE]: require("devtools/shared/resources/legacy-listeners/cache-storage"), + [ResourceWatcher.TYPES + .EXTENSION_STORAGE]: require("devtools/shared/resources/legacy-listeners/extension-storage"), + [ResourceWatcher.TYPES + .INDEXED_DB]: require("devtools/shared/resources/legacy-listeners/indexed-db"), + [ResourceWatcher.TYPES + .NETWORK_EVENT_STACKTRACE]: require("devtools/shared/resources/legacy-listeners/network-event-stacktraces"), + [ResourceWatcher.TYPES + .SOURCE]: require("devtools/shared/resources/legacy-listeners/source"), +}; + +// Optional transformers for each type of resource. +// Each module added here should be a function that will receive the resource, the target, โฆ +// and perform some transformation on the resource before it will be emitted. +// This is a good place to handle backward compatibility and manual resource marshalling. +const ResourceTransformers = { + [ResourceWatcher.TYPES + .CONSOLE_MESSAGE]: require("devtools/shared/resources/transformers/console-messages"), + [ResourceWatcher.TYPES + .ERROR_MESSAGE]: require("devtools/shared/resources/transformers/error-messages"), + [ResourceWatcher.TYPES + .LOCAL_STORAGE]: require("devtools/shared/resources/transformers/storage-local-storage.js"), + [ResourceWatcher.TYPES + .SESSION_STORAGE]: require("devtools/shared/resources/transformers/storage-session-storage.js"), + [ResourceWatcher.TYPES + .NETWORK_EVENT]: require("devtools/shared/resources/transformers/network-events"), +}; diff --git a/devtools/shared/resources/target-list.js b/devtools/shared/resources/target-list.js new file mode 100644 index 0000000000..a23b531364 --- /dev/null +++ b/devtools/shared/resources/target-list.js @@ -0,0 +1,607 @@ +/* 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/. */ + +"use strict"; + +const Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const BROWSERTOOLBOX_FISSION_ENABLED = "devtools.browsertoolbox.fission"; + +const { + LegacyProcessesWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher"); +const { + LegacyServiceWorkersWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher"); +const { + LegacySharedWorkersWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher"); +const { + LegacyWorkersWatcher, +} = require("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher"); + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "TargetFactory", + "devtools/client/framework/target", + true +); + +class TargetList extends EventEmitter { + /** + * This class helps managing, iterating over and listening for Targets. + * + * It exposes: + * - the top level target, typically the main process target for the browser toolbox + * or the browsing context target for a regular web toolbox + * - target of remoted iframe, in case Fission is enabled and some <iframe> + * are running in a distinct process + * - target switching. If the top level target changes for a new one, + * all the targets are going to be declared as destroyed and the new ones + * will be notified to the user of this API. + * + * @fires target-tread-wrong-order-on-resume : An event that is emitted when resuming + * the thread throws with the "wrongOrder" error. + * + * @param {RootFront} rootFront + * The root front. + * @param {TargetFront} targetFront + * The top level target to debug. Note that in case of target switching, + * this may be replaced by a new one over time. + */ + constructor(rootFront, targetFront) { + super(); + + this.rootFront = rootFront; + + // Once we have descriptor for all targets we create a toolbox for, + // we should try to only pass the descriptor to the Toolbox constructor, + // and, only receive the root and descriptor front as an argument to TargetList. + // Bug 1573779, we only miss descriptors for workers. + this.descriptorFront = targetFront.descriptorFront; + + // Note that this is a public attribute, used outside of this class + // and helps knowing what is the current top level target we debug. + this.targetFront = targetFront; + targetFront.setTargetType(this.getTargetType(targetFront)); + targetFront.setIsTopLevel(true); + + // Until Watcher actor notify about new top level target when navigating to another process + // we have to manually switch to a new target from the client side + this.onLocalTabRemotenessChange = this.onLocalTabRemotenessChange.bind( + this + ); + if (this.descriptorFront?.isLocalTab) { + this.descriptorFront.on( + "remoteness-change", + this.onLocalTabRemotenessChange + ); + } + + // Reports if we have at least one listener for the given target type + this._listenersStarted = new Set(); + + // List of all the target fronts + this._targets = new Set(); + // {Map<Function, Set<targetFront>>} A Map keyed by `onAvailable` function passed to + // `watchTargets`, whose initial value is a Set of the existing target fronts at the + // time watchTargets is called. + this._pendingWatchTargetInitialization = new Map(); + + // Add the top-level target to debug to the list of targets. + this._targets.add(targetFront); + + // Listeners for target creation and destruction + this._createListeners = new EventEmitter(); + this._destroyListeners = new EventEmitter(); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + + this.legacyImplementation = { + process: new LegacyProcessesWatcher( + this, + this._onTargetAvailable, + this._onTargetDestroyed + ), + worker: new LegacyWorkersWatcher( + this, + this._onTargetAvailable, + this._onTargetDestroyed + ), + shared_worker: new LegacySharedWorkersWatcher( + this, + this._onTargetAvailable, + this._onTargetDestroyed + ), + service_worker: new LegacyServiceWorkersWatcher( + this, + this._onTargetAvailable, + this._onTargetDestroyed + ), + }; + + // Public flag to allow listening for workers even if the fission pref is off + // This allows listening for workers in the content toolbox outside of fission contexts + // For now, this is only toggled by tests. + this.listenForWorkers = + this.rootFront.traits.workerConsoleApiMessagesDispatchedToMainThread === + false; + this.listenForServiceWorkers = false; + this.destroyServiceWorkersOnNavigation = false; + } + + // Called whenever a new Target front is available. + // Either because a target was already available as we started calling startListening + // or if it has just been created + async _onTargetAvailable(targetFront, isTargetSwitching = false) { + if (this._targets.has(targetFront)) { + // The top level target front can be reported via listProcesses in the + // case of the BrowserToolbox. For any other target, log an error if it is + // already registered. + if (targetFront != this.targetFront) { + console.error( + "Target is already registered in the TargetList", + targetFront.actorID + ); + } + return; + } + + if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) { + return; + } + + // Handle top level target switching + // Note that, for now, `_onTargetAvailable` isn't called for the *initial* top level target. + // i.e. the one that is passed to TargetList constructor. + if (targetFront.isTopLevel) { + // First report that all existing targets are destroyed + for (const target of this._targets) { + // We only consider the top level target to be switched + const isDestroyedTargetSwitching = target == this.targetFront; + this._onTargetDestroyed(target, isDestroyedTargetSwitching); + } + // Stop listening to legacy listeners as we now have to listen + // on the new target. + this.stopListening({ onlyLegacy: true }); + + // Clear the cached target list + this._targets.clear(); + + // Update the reference to the memoized top level target + this.targetFront = targetFront; + } + + // Map the descriptor typeName to a target type. + const targetType = this.getTargetType(targetFront); + targetFront.setTargetType(targetType); + + this._targets.add(targetFront); + try { + await targetFront.attachAndInitThread(this); + } catch (e) { + console.error("Error when attaching target:", e); + this._targets.delete(targetFront); + return; + } + + for (const targetFrontsSet of this._pendingWatchTargetInitialization.values()) { + targetFrontsSet.delete(targetFront); + } + + // Then, once the target is attached, notify the target front creation listeners + await this._createListeners.emitAsync(targetType, { + targetFront, + isTargetSwitching, + }); + + // Re-register the listeners as the top level target changed + // and some targets are fetched from it + if (targetFront.isTopLevel) { + await this.startListening({ onlyLegacy: true }); + } + + // To be consumed by tests triggering frame navigations, spawning workers... + this.emitForTests("processed-available-target", targetFront); + } + + _onTargetDestroyed(targetFront, isTargetSwitching = false) { + this._destroyListeners.emit(targetFront.targetType, { + targetFront, + isTargetSwitching, + }); + this._targets.delete(targetFront); + } + + _setListening(type, value) { + if (value) { + this._listenersStarted.add(type); + } else { + this._listenersStarted.delete(type); + } + } + + _isListening(type) { + return this._listenersStarted.has(type); + } + + hasTargetWatcherSupport(type) { + return !!this.watcherFront?.traits[type]; + } + + /** + * Start listening for targets from the server + * + * Interact with the actors in order to start listening for new types of targets. + * This will fire the _onTargetAvailable function for all already-existing targets, + * as well as the next one to be created. It will also call _onTargetDestroyed + * everytime a target is reported as destroyed by the actors. + * By the time this function resolves, all the already-existing targets will be + * reported to _onTargetAvailable. + * + * @param Object options + * Dictionary object with `onlyLegacy` optional boolean. + * If true, we wouldn't register listener set on the Watcher Actor, + * but still register listeners set via Legacy Listeners. + */ + async startListening({ onlyLegacy = false } = {}) { + // Cache the Watcher once for all, the first time we call `startListening()`. + // This `watcherFront` attribute may be then used in any function in TargetList or ResourceWatcher after this. + if (!this.watcherFront) { + // Bug 1675763: Watcher actor is not available in all situations yet. + const supportsWatcher = this.descriptorFront?.traits?.watcher; + if (supportsWatcher) { + this.watcherFront = await this.descriptorFront.getWatcher(); + } + } + + let types = []; + if (this.targetFront.isParentProcess) { + const fissionBrowserToolboxEnabled = Services.prefs.getBoolPref( + BROWSERTOOLBOX_FISSION_ENABLED + ); + if (fissionBrowserToolboxEnabled) { + types = TargetList.ALL_TYPES; + } + } else if (this.targetFront.isLocalTab) { + types = [TargetList.TYPES.FRAME]; + } + if (this.listenForWorkers && !types.includes(TargetList.TYPES.WORKER)) { + types.push(TargetList.TYPES.WORKER); + } + if ( + this.listenForWorkers && + !types.includes(TargetList.TYPES.SHARED_WORKER) + ) { + types.push(TargetList.TYPES.SHARED_WORKER); + } + if ( + this.listenForServiceWorkers && + !types.includes(TargetList.TYPES.SERVICE_WORKER) + ) { + types.push(TargetList.TYPES.SERVICE_WORKER); + } + + // If no pref are set to true, nor is listenForWorkers set to true, + // we won't listen for any additional target. Only the top level target + // will be managed. We may still do target-switching. + + for (const type of types) { + if (this._isListening(type)) { + continue; + } + this._setListening(type, true); + + // Only a few top level targets support the watcher actor at the moment (see WatcherActor + // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets. + if (this.hasTargetWatcherSupport(type)) { + // When we switch to a new top level target, we don't have to stop and restart + // Watcher listener as it is independant from the top level target. + // This isn't the case for some Legacy Listeners, which fetch targets from the top level target + if (onlyLegacy) { + continue; + } + if (!this._startedListeningToWatcher) { + this._startedListeningToWatcher = true; + this.watcherFront.on("target-available", this._onTargetAvailable); + this.watcherFront.on("target-destroyed", this._onTargetDestroyed); + } + await this.watcherFront.watchTargets(type); + continue; + } + if (this.legacyImplementation[type]) { + await this.legacyImplementation[type].listen(); + } else { + throw new Error(`Unsupported target type '${type}'`); + } + } + } + + /** + * Stop listening for targets from the server + * + * @param Object options + * Dictionary object with `onlyLegacy` optional boolean. + * If true, we wouldn't unregister listener set on the Watcher Actor, + * but still unregister listeners set via Legacy Listeners. + */ + stopListening({ onlyLegacy = false } = {}) { + for (const type of TargetList.ALL_TYPES) { + if (!this._isListening(type)) { + continue; + } + this._setListening(type, false); + + // Only a few top level targets support the watcher actor at the moment (see WatcherActor + // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets. + if (this.hasTargetWatcherSupport(type)) { + // When we switch to a new top level target, we don't have to stop and restart + // Watcher listener as it is independant from the top level target. + // This isn't the case for some Legacy Listeners, which fetch targets from the top level target + if (!onlyLegacy) { + this.watcherFront.unwatchTargets(type); + } + continue; + } + if (this.legacyImplementation[type]) { + this.legacyImplementation[type].unlisten(); + } else { + throw new Error(`Unsupported target type '${type}'`); + } + } + } + + getTargetType(target) { + const { typeName } = target; + if (typeName == "browsingContextTarget") { + return TargetList.TYPES.FRAME; + } + + if ( + typeName == "contentProcessTarget" || + typeName == "parentProcessTarget" + ) { + return TargetList.TYPES.PROCESS; + } + + if (typeName == "workerDescriptor" || typeName == "workerTarget") { + if (target.isSharedWorker) { + return TargetList.TYPES.SHARED_WORKER; + } + + if (target.isServiceWorker) { + return TargetList.TYPES.SERVICE_WORKER; + } + + return TargetList.TYPES.WORKER; + } + + throw new Error("Unsupported target typeName: " + typeName); + } + + _matchTargetType(type, target) { + return type === target.targetType; + } + + /** + * Listen for the creation and/or destruction of target fronts matching one of the provided types. + * + * @param {Array<String>} types + * The type of target to listen for. Constant of TargetList.TYPES. + * @param {Function} onAvailable + * Callback fired when a target has been just created or was already available. + * The function is called with the following arguments: + * - {TargetFront} targetFront: The target Front + * - {Boolean} isTargetSwitching: Is this target relates to a navigation and + * this replaced a previously available target, this flag will be true + * @param {Function} onDestroy + * Callback fired in case of target front destruction. + * The function is called with the same arguments than onAvailable. + */ + async watchTargets(types, onAvailable, onDestroy) { + if (typeof onAvailable != "function") { + throw new Error( + "TargetList.watchTargets expects a function as second argument" + ); + } + + // Notify about already existing target of these types + const targetFronts = [...this._targets].filter(targetFront => + types.includes(targetFront.targetType) + ); + this._pendingWatchTargetInitialization.set( + onAvailable, + new Set(targetFronts) + ); + const promises = targetFronts.map(async targetFront => { + // Attach the targets that aren't attached yet (e.g. the initial top-level target), + // and wait for the other ones to be fully attached. + try { + await targetFront.attachAndInitThread(this); + } catch (e) { + console.error("Error when attaching target:", e); + return; + } + + // It can happen that onAvailable was already called with this targetFront at + // this time (via _onTargetAvailable). If that's the case, we don't want to call + // onAvailable a second time. + if ( + this._pendingWatchTargetInitialization && + this._pendingWatchTargetInitialization.has(onAvailable) && + !this._pendingWatchTargetInitialization + .get(onAvailable) + .has(targetFront) + ) { + return; + } + + try { + // Ensure waiting for eventual async create listeners + // which may setup things regarding the existing targets + // and listen callsite may care about the full initialization + await onAvailable({ + targetFront, + isTargetSwitching: false, + }); + } catch (e) { + // Prevent throwing when onAvailable handler throws on one target + // so that it can try to register the other targets + console.error( + "Exception when calling onAvailable handler", + e.message, + e + ); + } + }); + + for (const type of types) { + this._createListeners.on(type, onAvailable); + if (onDestroy) { + this._destroyListeners.on(type, onDestroy); + } + } + + await Promise.all(promises); + this._pendingWatchTargetInitialization.delete(onAvailable); + } + + /** + * Stop listening for the creation and/or destruction of a given type of target fronts. + * See `watchTargets()` for documentation of the arguments. + */ + unwatchTargets(types, onAvailable, onDestroy) { + if (typeof onAvailable != "function") { + throw new Error( + "TargetList.unwatchTargets expects a function as second argument" + ); + } + + for (const type of types) { + this._createListeners.off(type, onAvailable); + if (onDestroy) { + this._destroyListeners.off(type, onDestroy); + } + } + this._pendingWatchTargetInitialization.delete(onAvailable); + } + + /** + * Retrieve all the current target fronts of a given type. + * + * @param {Array<String>} types + * The types of target to retrieve. Array of TargetList.TYPES + * @return {Array<TargetFront>} Array of target fronts matching any of the + * provided types. + */ + getAllTargets(types) { + if (!types?.length) { + throw new Error("getAllTargets expects a non-empty array of types"); + } + + const targets = [...this._targets].filter(target => + types.some(type => this._matchTargetType(type, target)) + ); + + return targets; + } + + /** + * For all the target fronts of a given type, retrieve all the target-scoped fronts of a given type. + * + * @param {String} targetType + * The type of target to iterate over. Constant of TargetList.TYPES. + * @param {String} frontType + * The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",... + */ + async getAllFronts(targetType, frontType) { + const fronts = []; + const targets = this.getAllTargets([targetType]); + for (const target of targets) { + const front = await target.getFront(frontType); + fronts.push(front); + } + return fronts; + } + + /** + * This function is triggered by an event sent by the TabDescriptor when + * the tab navigates to a distinct process. + * + * @param TargetFront targetFront + * The BrowsingContextTargetFront instance that navigated to another process + */ + async onLocalTabRemotenessChange(targetFront) { + // Cache the tab & client as this property will be nullified when the target is closed + const client = targetFront.client; + const localTab = targetFront.localTab; + + // By default, we do close the DevToolsClient when the target is destroyed. + // This happens when we close the toolbox (Toolbox.destroy calls Target.destroy), + // or when the tab is closes, the server emits tabDetached and the target + // destroy itself. + // Here, in the context of the process switch, the current target will be destroyed + // due to a tabDetached event and a we will create a new one. But we want to reuse + // the same client. + targetFront.shouldCloseClient = false; + + // Wait for the target to be destroyed so that TargetFactory clears its memoized target for this tab + await targetFront.once("target-destroyed"); + + // Fetch the new target from the existing client so that the new target uses the same client. + const newTarget = await TargetFactory.forTab(localTab, client); + + this.switchToTarget(newTarget); + } + + /** + * Called when the top level target is replaced by a new one. + * Typically when we navigate to another domain which requires to be loaded in a distinct process. + * + * @param {TargetFront} newTarget + * The new top level target to debug. + */ + async switchToTarget(newTarget) { + newTarget.setIsTopLevel(true); + + // Notify about this new target to creation listeners + await this._onTargetAvailable(newTarget, true); + + this.emit("switched-target", newTarget); + } + + isTargetRegistered(targetFront) { + return this._targets.has(targetFront); + } + + isDestroyed() { + return this._isDestroyed; + } + + destroy() { + this.stopListening(); + this._createListeners.off(); + this._destroyListeners.off(); + this._isDestroyed = true; + } +} + +/** + * All types of target: + */ +TargetList.TYPES = TargetList.prototype.TYPES = { + PROCESS: "process", + FRAME: "frame", + WORKER: "worker", + SHARED_WORKER: "shared_worker", + SERVICE_WORKER: "service_worker", +}; +TargetList.ALL_TYPES = TargetList.prototype.ALL_TYPES = Object.values( + TargetList.TYPES +); + +module.exports = { TargetList }; diff --git a/devtools/shared/resources/tests/.eslintrc.js b/devtools/shared/resources/tests/.eslintrc.js new file mode 100644 index 0000000000..3d0bd99e1b --- /dev/null +++ b/devtools/shared/resources/tests/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/shared/resources/tests/browser.ini b/devtools/shared/resources/tests/browser.ini new file mode 100644 index 0000000000..afbc008130 --- /dev/null +++ b/devtools/shared/resources/tests/browser.ini @@ -0,0 +1,64 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/test-actor.js + head.js + network_document.html + early_console_document.html + fission_document.html + fission_iframe.html + service-worker-sources.js + sources.html + sources.js + style_document.css + style_document.html + style_iframe.css + style_iframe.html + test_service_worker.js + test_sw_page.html + test_sw_page_worker.js + test_worker.js + websocket_backend_wsh.py + websocket_frontend.html + worker-sources.js + +[browser_browser_resources_console_messages.js] +[browser_resources_client_caching.js] +[browser_resources_console_messages.js] +[browser_resources_console_messages_workers.js] +[browser_resources_css_changes.js] +[browser_resources_css_messages.js] +[browser_resources_document_events.js] +[browser_resources_error_messages.js] +[browser_resources_getAllResources.js] +[browser_resources_network_event_stacktraces.js] +[browser_resources_network_events.js] +[browser_resources_platform_messages.js] +[browser_resources_root_node.js] +[browser_resources_several_resources.js] +[browser_resources_sources.js] +[browser_resources_stylesheets.js] +skip-if = fission # Disable frequent fission intermittents Bug 1675020 +[browser_resources_target_destroy.js] +[browser_resources_target_resources_race.js] +[browser_resources_target_switching.js] +[browser_resources_websocket.js] +[browser_target_list_browser_workers.js] +[browser_target_list_frames.js] +[browser_target_list_getAllTargets.js] +[browser_target_list_preffedoff.js] +[browser_target_list_processes.js] +[browser_target_list_service_workers.js] +[browser_target_list_service_workers_navigation.js] +skip-if = fission +# There are several issues to test TargetList navigation scenarios with fission. +# Without a toolbox linked to the target-list, the target list cannot switch +# targets. The legacy worker watchers are also not designed to support target +# switching, since they set this.target = targetList.targetFront just once in +# their constructor. +[browser_target_list_switchToTarget.js] +[browser_target_list_tab_workers.js] +[browser_target_list_watchTargets.js] diff --git a/devtools/shared/resources/tests/browser_browser_resources_console_messages.js b/devtools/shared/resources/tests/browser_browser_resources_console_messages.js new file mode 100644 index 0000000000..67bd5af635 --- /dev/null +++ b/devtools/shared/resources/tests/browser_browser_resources_console_messages.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CONSOLE_MESSAGE for the whole browser + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URL = URL_ROOT_SSL + "early_console_document.html"; + +add_task(async function() { + // Enable Multiprocess Browser Toolbox (it's still disabled for non-Nightly builds). + await pushPref("devtools.browsertoolbox.fission", true); + + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + info( + "Log some messages *before* calling ResourceWatcher.watchResources in order to " + + "assert the behavior of already existing messages." + ); + console.log("foobar"); + + info("Wait for existing browser mochitest log"); + await waitForNextResource( + resourceWatcher, + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === "foobar"; + }, + } + ); + ok(true, "The existing log was retrieved"); + + // We can't use waitForNextResource here as we have to ensure + // waiting for watchResource resolution before doing the console log. + let resolveMochitestRuntimeLog; + const onMochitestRuntimeLog = new Promise(resolve => { + resolveMochitestRuntimeLog = resolve; + }); + const onAvailable = resources => { + if ( + resources.some(resource => resource.message.arguments[0] == "foobar2") + ) { + resourceWatcher.unwatchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + resolveMochitestRuntimeLog(); + } + }; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + ignoreExistingResources: true, + onAvailable, + } + ); + console.log("foobar2"); + + info("Wait for runtime browser mochitest log"); + await onMochitestRuntimeLog; + ok(true, "The runtime log was retrieved"); + + const onEarlyLog = waitForNextResource( + resourceWatcher, + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: true, + predicate({ message }) { + return message.arguments[0] === "early-page-log"; + }, + } + ); + await addTab(TEST_URL); + info("Wait for early page log"); + await onEarlyLog; + ok(true, "The early page log was retrieved"); + + targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_client_caching.js b/devtools/shared/resources/tests/browser_resources_client_caching.js new file mode 100644 index 0000000000..b758318a13 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_client_caching.js @@ -0,0 +1,362 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the cache mechanism of the ResourceWatcher. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URI = "data:text/html;charset=utf-8,Cache Test"; + +add_task(async function() { + info("Test whether multiple listener can get same cached resources"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + } + ); + + assertContents(cachedResources1, messages); + assertResources(cachedResources2, cachedResources1); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info( + "Test whether the cache is reflecting existing resources and additional resources" + ); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil(() => availableResources.length === allMessages.length); + + info("Register second listener to get the cached resources"); + const cachedResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertContents(availableResources, allMessages); + assertResources(cachedResources, availableResources); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info("Test whether the cache is cleared when navigation"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info("Reload the page"); + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(tab); + await onReloaded; + + info("Register second listener"); + const cachedResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + is(cachedResources.length, 0, "The cache in ResourceWatcher is cleared"); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info("Test with multiple resource types"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceWatcher.watchResources( + [ + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + ResourceWatcher.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as console message"); + const consoleMessages1 = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, consoleMessages1); + + info("Add message as error message"); + const errorMessages = ["document.doTheImpossible();"]; + await triggerErrors(tab.linkedBrowser, errorMessages); + + info("Add messages as console message again"); + const consoleMessages2 = ["d", "e"]; + await logMessages(tab.linkedBrowser, consoleMessages2); + + info("Wait until the getting all available resources"); + const totalResourceCount = + consoleMessages1.length + errorMessages.length + consoleMessages2.length; + await waitUntil(() => { + return availableResources.length === totalResourceCount; + }); + + info("Register listener to get the cached resources"); + const cachedResources = []; + await resourceWatcher.watchResources( + [ + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + ResourceWatcher.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertResources(cachedResources, availableResources); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info("Test multiple listeners with/without ignoreExistingResources"); + await testIgnoreExistingResources(true); + await testIgnoreExistingResources(false); +}); + +async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) { + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + ignoreExistingResources: isFirstListenerIgnoreExisting, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + ignoreExistingResources: !isFirstListenerIgnoreExisting, + } + ); + + const cachedResourcesWithFlag = isFirstListenerIgnoreExisting + ? cachedResources1 + : cachedResources2; + const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting + ? cachedResources2 + : cachedResources1; + + info("Check the existing resources both listeners got"); + assertContents(cachedResourcesWithFlag, []); + assertContents(cachedResourcesWithoutFlag, existingMessages); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + await waitUntil( + () => cachedResourcesWithFlag.length === additionalMessages.length + ); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil( + () => cachedResourcesWithoutFlag.length === allMessages.length + ); + + info("Check the resources after adding messages"); + assertContents(cachedResourcesWithFlag, additionalMessages); + assertContents(cachedResourcesWithoutFlag, allMessages); + + await targetList.destroy(); + await client.close(); +} + +add_task(async function() { + info("Test that onAvailable is not called with an empty resources array"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + let onAvailableCallCount = 0; + const onAvailable = resources => { + ok( + resources.length > 0, + "onAvailable is called with a non empty resources array" + ); + availableResources.push(...resources); + onAvailableCallCount++; + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + is(availableResources.length, 0, "availableResources array is empty"); + is(onAvailableCallCount, 0, "onAvailable was never called"); + + info("Add messages as console message"); + await logMessages(tab.linkedBrowser, ["expected message"]); + + await waitUntil(() => availableResources.length === 1); + is(availableResources.length, 1, "availableResources array has one item"); + is(onAvailableCallCount, 1, "onAvailable was called only once"); + is( + availableResources[0].message.arguments[0], + "expected message", + "onAvailable was called with the expected resource" + ); + + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + await targetList.destroy(); + await client.close(); +}); + +function assertContents(resources, expectedMessages) { + is( + resources.length, + expectedMessages.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < expectedMessages.length; i++) { + const resource = resources[i]; + const message = resource.message.arguments[0]; + const expectedMessage = expectedMessages[i]; + is(message, expectedMessage, `The ${i}th content is correct`); + } +} + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + ok(resource === expectedResource, `The ${i}th resource is correct`); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} + +async function triggerErrors(browser, errorScripts) { + for (const errorScript of errorScripts) { + await ContentTask.spawn(browser, errorScript, expr => { + const document = content.document; + const container = document.createElement("script"); + document.body.appendChild(container); + container.textContent = expr; + container.remove(); + }); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_console_messages.js b/devtools/shared/resources/tests/browser_resources_console_messages.js new file mode 100644 index 0000000000..1be72427b1 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_console_messages.js @@ -0,0 +1,460 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CONSOLE_MESSAGE +// +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html +// And now more. Once we remove the console actor's startListeners in favor of watcher class +// We could remove that other old test. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; + +add_task(async function() { + info("Execute test in top level document"); + await testTabConsoleMessagesResources(false); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false); + + info("Execute test in an iframe document, possibly remote with fission"); + await testTabConsoleMessagesResources(true); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true); +}); + +async function testTabConsoleMessagesResources(executeInIframe) { + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + "Log some messages *before* calling ResourceWatcher.watchResources in order to " + + "assert the behavior of already existing messages." + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + + let runtimeDoneResolve; + const expectedExistingCalls = getExpectedExistingConsoleCalls( + targetDocumentUrl + ); + const expectedRuntimeCalls = getExpectedRuntimeConsoleCalls( + targetDocumentUrl + ); + const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + if (resource.message.arguments?.[0] === "[WORKER] started") { + // XXX Ignore message from workers as we can't know when they're logged, and we + // have a dedicated test for them (browser_resources_console_messages_workers.js). + continue; + } + + is( + resource.resourceType, + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + "Received a message" + ); + ok(resource.message, "message is wrapped into a message attribute"); + const expected = (expectedExistingCalls.length > 0 + ? expectedExistingCalls + : expectedRuntimeCalls + ).shift(); + checkConsoleAPICall(resource.message, expected); + if (expectedRuntimeCalls.length == 0) { + runtimeDoneResolve(); + } + } + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + is( + expectedExistingCalls.length, + 0, + "Got the expected number of existing messages" + ); + + info( + "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + + info("Waiting for all runtime messages"); + await onRuntimeDone; + + is( + expectedRuntimeCalls.length, + 0, + "Got the expected number of runtime messages" + ); + + targetList.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +async function testTabConsoleMessagesResourcesWithIgnoreExistingResources( + executeInIframe +) { + info("Test ignoreExistingResources option for console messages"); + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + "Check whether onAvailable will not be called with existing console messages" + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const availableResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing console messages" + ); + + info( + "Check whether onAvailable will be called with the future console messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + const expectedRuntimeConsoleCalls = getExpectedRuntimeConsoleCalls( + targetDocumentUrl + ); + await waitUntil( + () => availableResources.length === expectedRuntimeConsoleCalls.length + ); + const expectedTargetFront = + executeInIframe && isFissionEnabled() + ? targetList + .getAllTargets([targetList.TYPES.FRAME]) + .find(target => target.url == IFRAME_URL) + : targetList.targetFront; + for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) { + const { message, targetFront } = availableResources[i]; + is( + targetFront, + expectedTargetFront, + "The targetFront property is the expected one" + ); + const expected = expectedRuntimeConsoleCalls[i]; + checkConsoleAPICall(message, expected); + } + + await targetList.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +async function logExistingMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + return evalInBrowsingContext(browsingContext, function pageScript() { + console.log("foobarBaz-log", undefined); + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.body); + }); +} + +/** + * Helper function similar to spawn, but instead of executing the script + * as a Frame Script, with privileges and including test harness in stacktraces, + * execute the script as a regular page script, without privileges and without any + * preceding stack. + * + * @param {BrowsingContext} The browsing context into which the script should be evaluated + * @param {Function|String} The JS to execute in the browsing context + * + * @return {Promise} Which resolves once the JS is done executing in the page + */ +function evalInBrowsingContext(browsingContext, script) { + return SpecialPowers.spawn(browsingContext, [String(script)], expr => { + const document = content.document; + const scriptEl = document.createElement("script"); + document.body.appendChild(scriptEl); + // Force the immediate execution of the stringified JS function passed in `expr` + scriptEl.textContent = "new " + expr; + scriptEl.remove(); + }); +} + +// For both existing and runtime messages, we execute console API +// from a page script evaluated via evalInBrowsingContext. +// Records here the function used to execute the script in the page. +const EXPECTED_FUNCTION_NAME = "pageScript"; + +const NUMBER_REGEX = /^\d+$/; + +function getExpectedExistingConsoleCalls(documentFilename) { + return [ + { + level: "log", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + level: "info", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + level: "warn", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + ]; +} + +const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a"); +function getExpectedRuntimeConsoleCalls(documentFilename) { + const defaultStackFrames = [ + // This is the usage of "new " + expr from `evalInBrowsingContext` + { + filename: documentFilename, + lineNumber: 1, + columnNumber: NUMBER_REGEX, + }, + ]; + + return [ + { + level: "log", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + level: "log", + arguments: ["Float from not a number: NaN"], + }, + { + level: "log", + arguments: ["Float from string: 1.200000"], + }, + { + level: "log", + arguments: ["Float from number: 1.300000"], + }, + { + level: "info", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + level: "warn", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + { + level: "debug", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: [{ type: "null" }], + }, + { + level: "trace", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + stacktrace: [ + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + level: "dir", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "HTMLDocument", + }, + { + type: "object", + actor: /[a-z]/, + class: "Location", + }, + ], + }, + { + level: "log", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: [ + "foo", + { + type: "longString", + initial: longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ), + length: longString.length, + actor: /[a-z]/, + }, + ], + }, + { + level: "error", + filename: documentFilename, + functionName: "fromAsmJS", + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-asmjs-error", { type: "undefined" }], + + stacktrace: [ + { + filename: documentFilename, + functionName: "fromAsmJS", + }, + { + filename: documentFilename, + functionName: "inAsmJS2", + }, + { + filename: documentFilename, + functionName: "inAsmJS1", + }, + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + level: "log", + filename: gTestPath, + functionName: "frameScript", + timeStamp: NUMBER_REGEX, + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "Restricted", + }, + ], + }, + ]; +} + +async function logRuntimeMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + // First inject LONG_STRING_LENGTH in global scope it order to easily use it after + await evalInBrowsingContext( + browsingContext, + `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}` + ); + await evalInBrowsingContext(browsingContext, function pageScript() { + const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a"); + + console.log("foobarBaz-log", undefined); + + console.log("Float from not a number: %f", "foo"); + console.log("Float from string: %f", "1.2"); + console.log("Float from number: %f", 1.3); + + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.documentElement); + console.debug(null); + console.trace(); + console.dir(document, location); + console.log("foo", _longString); + + function fromAsmJS() { + console.error("foobarBaz-asmjs-error", undefined); + } + + (function(global, foreign) { + "use asm"; + function inAsmJS2() { + foreign.fromAsmJS(); + } + function inAsmJS1() { + inAsmJS2(); + } + return inAsmJS1; + })(null, { fromAsmJS: fromAsmJS })(); + }); + await SpecialPowers.spawn(browsingContext, [], function frameScript() { + const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true }); + const sandboxObj = sandbox.eval("new Object"); + content.console.log(sandboxObj); + }); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkConsoleAPICall(call, expected) { + is( + call.arguments?.length || 0, + expected.arguments?.length || 0, + "number of arguments" + ); + + checkObject(call, expected); +} diff --git a/devtools/shared/resources/tests/browser_resources_console_messages_workers.js b/devtools/shared/resources/tests/browser_resources_console_messages_workers.js new file mode 100644 index 0000000000..a31388bc32 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_console_messages_workers.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CONSOLE_MESSAGE in workers + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe.html`; + +add_task(async function() { + // Set the following pref to false as it's the one that enables direct connection + // to the worker targets + await pushPref("dom.worker.console.dispatch_events_to_main_thread", false); + + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab, + { listenForWorkers: true } + ); + + info("Wait for the workers (from the main page and the iframe) to be ready"); + const targets = []; + await new Promise(resolve => { + const onAvailable = async ({ targetFront }) => { + targets.push(targetFront); + if (targets.length === 2) { + resolve(); + } + }; + targetList.watchTargets([targetList.TYPES.WORKER], onAvailable); + }); + + // The worker logs a message right when it starts, containing its location, so we can + // assert that we get the logs from the worker spawned in the content page and from the + // worker spawned in the iframe. + info("Check that we receive the cached messages"); + + const resources = []; + const onAvailable = innerResources => { + for (const resource of innerResources) { + // Ignore resources from non worker targets + if (!resource.targetFront.isWorkerTarget) { + continue; + } + + resources.push(resource); + } + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + is(resources.length, 2, "Got the expected number of existing messages"); + const startLogFromWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + const startLogFromWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + + checkStartWorkerLogMessage( + startLogFromWorkerInMainPage, + `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + checkStartWorkerLogMessage( + startLogFromWorkerInIframe, + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + let messageCount = resources.length; + + info( + "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from main page"); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from iframe"); + }); + }); + + // Wait until the 2 new logs are available + await waitUntil(() => resources.length === messageCount + 2); + const liveMessageFromWorkerInMainPage = resources.find( + ({ message }) => message.arguments[1] === "live message from main page" + ); + const liveMessageFromWorkerInIframe = resources.find( + ({ message }) => message.arguments[1] === "live message from iframe" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInMainPage, + "live message from main page" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInIframe, + "live message from iframe" + ); + + // update the current number of resources received + messageCount = resources.length; + + info("Now spawn new workers and log messages in main page and iframe"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [WORKER_FILE], + async workerUrl => { + const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + spawnedWorker.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from main page", + }); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => { + const spawnedWorkerInIframe = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + spawnedWorkerInIframe.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from iframe", + }); + }); + } + ); + + info( + "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage" + ); + await waitUntil( + () => resources.length === messageCount + 4, + `Couldn't get the expected number of resources (expected ${messageCount + + 4}, got ${resources.length})` + ); + const startLogFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + const startLogFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + const liveMessageFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from main page" + ); + const liveMessageFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from iframe" + ); + + checkStartWorkerLogMessage( + startLogFromSpawnedWorkerInMainPage, + `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + checkStartWorkerLogMessage( + startLogFromSpawnedWorkerInIframe, + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInMainPage, + "live message in spawned worker from main page" + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInIframe, + "live message in spawned worker from iframe" + ); + // update the current number of resources received + messageCount = resources.length; + + info( + "Add a remote iframe on the same origin we already have an iframe and check we get the messages" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [IFRAME_FILE], + async iframeUrl => { + const iframe = content.document.createElement("iframe"); + iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`; + content.document.body.append(iframe); + } + ); + + info("Wait until the new log is available"); + await waitUntil( + () => resources.length === messageCount + 1, + `Couldn't get the expected number of resources (expected ${messageCount + + 1}, got ${resources.length})` + ); + const startLogFromWorkerInSecondIframe = resources[resources.length - 1]; + checkStartWorkerLogMessage( + startLogFromWorkerInSecondIframe, + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe` + ); + + targetList.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function checkStartWorkerLogMessage(resource, expectedUrl) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER] started", "Got the expected first argument"); + is(secondArg, expectedUrl, "expected url was logged"); + is( + thirdArg?._grip?.class, + "DedicatedWorkerGlobalScope", + "the global scope was logged as expected" + ); +} + +function checkLogInWorkerMessage(resource, expectedMessage) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER]", "Got the expected first argument"); + is(secondArg, expectedMessage, "expected message was logged"); + is( + thirdArg?._grip?.class, + "MessageEvent", + "the message event object was logged as expected" + ); +} diff --git a/devtools/shared/resources/tests/browser_resources_css_changes.js b/devtools/shared/resources/tests/browser_resources_css_changes.js new file mode 100644 index 0000000000..d81953e75e --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_css_changes.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CSS_CHANGE. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + // Open a test tab + const tab = await addTab( + "data:text/html,<body style='color: lime;'>CSS Changes</body>" + ); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + // CSS_CHANGE watcher doesn't record modification made before watching, + // so we have to start watching before doing any DOM mutation. + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], { + onAvailable: () => {}, + }); + + const { walker } = await targetList.targetFront.getFront("inspector"); + const nodeList = await walker.querySelectorAll(walker.rootNode, "body"); + const body = (await nodeList.items())[0]; + const style = ( + await body.inspectorFront.pageStyle.getApplied(body, { + skipPseudo: false, + }) + )[0]; + + info( + "Check whether ResourceWatcher catches CSS change that fired before starting to watch" + ); + await setProperty(style.rule, 0, "color", "black"); + + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], { + onAvailable: resources => availableResources.push(...resources), + }); + assertResource( + availableResources[0], + { index: 0, property: "color", value: "black" }, + { index: 0, property: "color", value: "lime" } + ); + + info( + "Check whether ResourceWatcher catches CSS change after the property changed" + ); + await setProperty(style.rule, 0, "background-color", "pink"); + await waitUntil(() => availableResources.length === 2); + assertResource( + availableResources[1], + { index: 0, property: "background-color", value: "pink" }, + { index: 0, property: "color", value: "black" } + ); + + info("Check whether ResourceWatcher catches CSS change of disabling"); + await setPropertyEnabled(style.rule, 0, "background-color", false); + await waitUntil(() => availableResources.length === 3); + assertResource(availableResources[2], null, { + index: 0, + property: "background-color", + value: "pink", + }); + + info("Check whether ResourceWatcher catches CSS change of new property"); + await createProperty(style.rule, 1, "font-size", "100px"); + await waitUntil(() => availableResources.length === 4); + assertResource( + availableResources[3], + { index: 1, property: "font-size", value: "100px" }, + null + ); + + info("Check whether ResourceWatcher sends all resources added in this test"); + const existingResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], { + onAvailable: resources => existingResources.push(...resources), + }); + await waitUntil(() => existingResources.length === 4); + is(availableResources[0], existingResources[0], "1st resource is correct"); + is(availableResources[1], existingResources[1], "2nd resource is correct"); + is(availableResources[2], existingResources[2], "3rd resource is correct"); + is(availableResources[3], existingResources[3], "4th resource is correct"); + + await targetList.destroy(); + await client.close(); +}); + +function assertResource(resource, expectedAddedChange, expectedRemovedChange) { + if (expectedAddedChange) { + is(resource.add.length, 1, "The number of added changes is correct"); + assertChange(resource.add[0], expectedAddedChange); + } else { + is(resource.add, null, "There is no added changes"); + } + + if (expectedRemovedChange) { + is(resource.remove.length, 1, "The number of removed changes is correct"); + assertChange(resource.remove[0], expectedRemovedChange); + } else { + is(resource.remove, null, "There is no removed changes"); + } +} + +function assertChange(change, expected) { + is(change.index, expected.index, "The index of change is correct"); + is(change.property, expected.property, "The property of change is correct"); + is(change.value, expected.value, "The value of change is correct"); +} + +async function setProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setProperty(index, property, value, ""); + await modifications.apply(); +} + +async function createProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.createProperty(index, property, value, "", true); + await modifications.apply(); +} + +async function setPropertyEnabled(rule, index, property, isEnabled) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setPropertyEnabled(index, property, isEnabled); + await modifications.apply(); +} diff --git a/devtools/shared/resources/tests/browser_resources_css_messages.js b/devtools/shared/resources/tests/browser_resources_css_messages.js new file mode 100644 index 0000000000..0e82bacd57 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_css_messages.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CSS_MESSAGE +// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); +const { MESSAGE_CATEGORY } = require("devtools/shared/constants"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<meta charset=utf8> + <style> + html { + color: bloup; + } + </style>Test CSS Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`; + +add_task(async function() { + await testWatchingCssMessages(); + await testWatchingCachedCssMessages(); +}); + +async function testWatchingCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const receivedMessages = []; + const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction( + targetList, + receivedMessages + ); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_MESSAGE], { + onAvailable, + }); + + info( + "Now log CSS warning *after* the call to ResourceWatcher.watchResources and after " + + "having received the existing message" + ); + // We need to wait for the first CSS Warning as it is not a cached message; when we + // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and + // if it is false, we re-parse the stylesheets to get the messages. + await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1); + + info("Trigger a CSS Warning"); + triggerCSSWarning(tab); + + info("Waiting for all expected CSS messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected CSS messages were received"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +async function testWatchingCachedCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + // By default, the CSS Parser does not emit warnings at all, for performance matter. + // Since we actually want the Parser to emit those messages _before_ we start listening + // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function() { + content.docShell.cssErrorReportingEnabled = true; + }); + + // Setting the docShell flag only indicates to the Parser that from now on, it should + // emit warnings. But it does not automatically emit warnings for the existing CSS + // errors in the stylesheets. So here we reload the tab, which will make the Parser + // parse the stylesheets again, this time emitting warnings. + const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + tab.linkedBrowser.reload(); + // wait for the tab to be fully loaded + await loaded; + // and trigger more CSS warnings + await triggerCSSWarning(tab); + + // At this point, all messages should be in the ConsoleService cache, and we can begin + // to watch and check that we do retrieve those messages. + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const receivedMessages = []; + const { onAvailable } = setupOnAvailableFunction( + targetList, + receivedMessages + ); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_MESSAGE], { + onAvailable, + }); + is(receivedMessages.length, 3, "Cached messages were retrieved as expected"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +function setupOnAvailableFunction(targetList, receivedMessages) { + // The expected messages are the CSS warnings: + // - one for the rule in the style element + // - two for the JS modified style we're doing in the test. + const expectedMessages = [ + { + pageError: { + errorMessage: /Expected color but found โbloupโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: /^\d+$/, + error: false, + warning: true, + }, + cssSelectors: "html", + }, + { + pageError: { + errorMessage: /Error in parsing value for โwidthโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: /^\d+$/, + error: false, + warning: true, + }, + }, + { + pageError: { + errorMessage: /Error in parsing value for โheightโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: /^\d+$/, + error: false, + warning: true, + }, + }, + ]; + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetList.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_css_messages")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(pageError); + + info( + `checking received css message #${index}: ${pageError.errorMessage}` + ); + ok(pageError, "The resource has a pageError attribute"); + checkObject(resource, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + return { onAvailable, onAllMessagesReceived }; +} + +/** + * Sets invalid values for width and height on the document's body style attribute. + */ +function triggerCSSWarning(tab) { + return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() { + content.document.body.style.width = "red"; + content.document.body.style.height = "blue"; + }); +} diff --git a/devtools/shared/resources/tests/browser_resources_document_events.js b/devtools/shared/resources/tests/browser_resources_document_events.js new file mode 100644 index 0000000000..bf6a66ed40 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_document_events.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around DOCUMENT_EVENT + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + await testDocumentEventResources(); + await testDocumentEventResourcesWithIgnoreExistingResources(); +}); + +async function testDocumentEventResources() { + info("Test ResourceWatcher for DOCUMENT_EVENT"); + + // Open a test tab + const tab = await addTab("data:text/html,Document Events"); + + // Create a TargetList for the test tab + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(client.mainRoot, target); + await targetList.startListening(); + + // Activate ResourceWatcher + const listener = new ResourceListener(); + const resourceWatcher = new ResourceWatcher(targetList); + + info( + "Check whether the document events are fired correctly even when the document was already loaded" + ); + const onLoadingAtInit = listener.once("dom-loading"); + const onInteractiveAtInit = listener.once("dom-interactive"); + const onCompleteAtInit = listener.once("dom-complete"); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.DOCUMENT_EVENT], { + onAvailable: parameters => listener.dispatch(parameters), + }); + await assertPromises(onLoadingAtInit, onInteractiveAtInit, onCompleteAtInit); + ok( + true, + "Document events are fired even when the document was already loaded" + ); + + info("Check whether the document events are fired correctly when reloading"); + const onLoadingAtReloaded = listener.once("dom-loading"); + const onInteractiveAtReloaded = listener.once("dom-interactive"); + const onCompleteAtReloaded = listener.once("dom-complete"); + gBrowser.reloadTab(tab); + await assertPromises( + onLoadingAtReloaded, + onInteractiveAtReloaded, + onCompleteAtReloaded + ); + ok(true, "Document events are fired after reloading"); + + await targetList.destroy(); + await client.close(); +} + +async function testDocumentEventResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for DOCUMENT_EVENT"); + + const tab = await addTab("data:text/html,Document Events"); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check whether the existing document events will not be fired"); + const documentEvents = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + }); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Check whether the future document events are fired"); + gBrowser.reloadTab(tab); + info("Wait for dom-loading, dom-interactive and dom-complete events"); + await waitUntil(() => documentEvents.length === 3); + assertEvents(...documentEvents); + + await targetList.destroy(); + await client.close(); +} + +async function assertPromises(onLoading, onInteractive, onComplete) { + const loadingEvent = await onLoading; + const interactiveEvent = await onInteractive; + const completeEvent = await onComplete; + assertEvents(loadingEvent, interactiveEvent, completeEvent); +} + +function assertEvents(loadingEvent, interactiveEvent, completeEvent) { + is( + typeof loadingEvent.time, + "number", + "Type of time attribute for loading event is correct" + ); + is( + typeof interactiveEvent.time, + "number", + "Type of time attribute for interactive event is correct" + ); + is( + typeof completeEvent.time, + "number", + "Type of time attribute for complete event is correct" + ); + + ok( + loadingEvent.time < interactiveEvent.time, + "Timestamp for interactive event is greater than loading event" + ); + ok( + interactiveEvent.time < completeEvent.time, + "Timestamp for complete event is greater than interactive event" + ); +} + +class ResourceListener { + _listeners = new Map(); + + dispatch(resources) { + for (const resource of resources) { + const resolve = this._listeners.get(resource.name); + if (resolve) { + resolve(resource); + this._listeners.delete(resource.name); + } + } + } + + once(resourceName) { + return new Promise(r => this._listeners.set(resourceName, r)); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_error_messages.js b/devtools/shared/resources/tests/browser_resources_error_messages.js new file mode 100644 index 0000000000..959a97e87e --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_error_messages.js @@ -0,0 +1,614 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around ERROR_MESSAGE +// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<meta charset=utf8>Test Error Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`; + +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testErrorMessagesResources(); + await testErrorMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testErrorMessagesResources() { + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const receivedMessages = []; + // The expected messages are the errors, twice (once for cached messages, once for live messages) + const expectedMessages = Array.from(expectedPageErrors.values()).concat( + Array.from(expectedPageErrors.values()) + ); + + info( + "Log some errors *before* calling ResourceWatcher.watchResources in order to assert" + + " the behavior of already existing messages." + ); + await triggerErrors(tab); + + let done; + const onAllErrorReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetList.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_page_errors")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(pageError); + + info(`checking received page error #${index}: ${pageError.errorMessage}`); + ok(pageError, "The resource has a pageError attribute"); + checkPageErrorResource(pageError, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + info( + "Now log errors *after* the call to ResourceWatcher.watchResources and after having" + + " received all existing messages" + ); + await BrowserTestUtils.waitForCondition( + () => receivedMessages.length === expectedPageErrors.size + ); + await triggerErrors(tab); + + info("Waiting for all expected errors to be received"); + await onAllErrorReceived; + ok(true, "All the expected errors were received"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +async function testErrorMessagesResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for ERROR_MESSAGE"); + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + "Check whether onAvailable will not be called with existing error messages" + ); + await triggerErrors(tab); + + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + }); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing error messages" + ); + + info( + "Check whether onAvailable will be called with the future error messages" + ); + await triggerErrors(tab); + + const expectedMessages = Array.from(expectedPageErrors.values()); + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const { pageError } = availableResources[i]; + const expected = expectedMessages[i]; + checkPageErrorResource(pageError, expected); + } + + Services.console.reset(); + await targetList.destroy(); + await client.close(); +} + +/** + * Triggers all the errors in the content page. + */ +async function triggerErrors(tab) { + for (const [expression, expected] of expectedPageErrors.entries()) { + if ( + !expected[noUncaughtException] && + !Services.appinfo.browserTabsRemoteAutostart + ) { + expectUncaughtException(); + } + + await ContentTask.spawn(tab.linkedBrowser, expression, function frameScript( + expr + ) { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = expr; + document.body.appendChild(scriptEl); + }); + + if (expected.isPromiseRejection) { + // Wait a bit after an uncaught promise rejection error, as they are not emitted + // right away. + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(res, 10)); + } + } +} + +function checkPageErrorResource(pageErrorResource, expected) { + // Let's remove test harness related frames in stacktrace + const clonedPageErrorResource = { ...pageErrorResource }; + if (clonedPageErrorResource.stacktrace) { + const index = clonedPageErrorResource.stacktrace.findIndex(frame => + frame.filename.startsWith("resource://testing-common/content-task.js") + ); + if (index > -1) { + clonedPageErrorResource.stacktrace = clonedPageErrorResource.stacktrace.slice( + 0, + index + ); + } + } + checkObject(clonedPageErrorResource, expected); +} + +const noUncaughtException = Symbol(); +const NUMBER_REGEX = /^\d+$/; + +const mdnUrl = path => + `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`; + +const expectedPageErrors = new Map([ + [ + "document.doTheImpossible();", + { + errorMessage: /doTheImpossible/, + errorMessageName: undefined, + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Not_a_function" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 10, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "(42).toString(0);", + { + errorMessage: /radix/, + errorMessageName: "JSMSG_BAD_RADIX", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 6, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;", + { + errorMessage: /read.only/, + errorMessageName: "JSMSG_READ_ONLY", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 23, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "([]).length = -1", + { + errorMessage: /array length/, + errorMessageName: "JSMSG_BAD_ARRAY_LENGTH", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Invalid_array_length" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 2, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'abc'.repeat(-1);", + { + errorMessage: /repeat count.*non-negative/, + errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'a'.repeat(2e28);", + { + errorMessage: /repeat count.*less than infinity/, + errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 5, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "77.1234.toExponential(-1);", + { + errorMessage: /out of range/, + errorMessageName: "JSMSG_PRECISION_RANGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Precision_range" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 9, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "function a() { return; 1 + 1; }", + { + errorMessage: /unreachable code/, + errorMessageName: "JSMSG_STMT_AFTER_RETURN", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: false, + warning: true, + info: false, + sourceId: null, + lineText: "function a() { return; 1 + 1; }", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Stmt_after_return" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: null, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "{let a, a;}", + { + errorMessage: /redeclaration of/, + errorMessageName: "JSMSG_REDECLARED_VAR", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + sourceId: null, + lineText: "{let a, a;}", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [], + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + notes: [ + { + messageBody: /Previously declared at line/, + frame: { + source: /test_page_errors/, + }, + }, + ], + }, + ], + [ + `var error = new TypeError("abc"); + error.name = "MyError"; + error.message = "here"; + throw error`, + { + errorMessage: /MyError: here/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 13, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "DOMTokenList.prototype.contains.call([])", + { + errorMessage: /does not implement interface/, + errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 33, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + ` + function promiseThrow() { + var error2 = new TypeError("abc"); + error2.name = "MyPromiseError"; + error2.message = "here2"; + return Promise.reject(error2); + } + promiseThrow()`, + { + errorMessage: /MyPromiseError: here2/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 6, + columnNumber: 24, + functionName: "promiseThrow", + }, + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 8, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: true, + isForwardedFromContentProcess: false, + [noUncaughtException]: true, + }, + ], +]); diff --git a/devtools/shared/resources/tests/browser_resources_getAllResources.js b/devtools/shared/resources/tests/browser_resources_getAllResources.js new file mode 100644 index 0000000000..c22f6e0b52 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_getAllResources.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test getAllResources function of the ResourceWatcher. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URI = "data:text/html;charset=utf-8,getAllResources test"; + +add_task(async function() { + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE) + .length, + 0, + "There is no resources at initial" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => availableResources.push(...resources); + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + info("Check the resources after some resources are available"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + await waitUntil(() => availableResources.length >= messages.length); + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE), + availableResources + ); + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.STYLESHEET), + [] + ); + + info("Check the resources after reloading"); + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(tab); + await onReloaded; + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE), + [] + ); + + info("Append some resources again to test unwatching"); + await logMessages(tab.linkedBrowser, messages); + await waitUntil( + () => + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE) + .length === messages.length + ); + + info("Check the resources after unwatching"); + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE), + [] + ); + + await targetList.destroy(); + await client.close(); +}); + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + ok(resource === expectedResource, `The ${i}th resource is correct`); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js new file mode 100644 index 0000000000..23500b231c --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around NETWORK_EVENT_STACKTRACE + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +const REQUEST_STUB = { + code: `await fetch("/request_post_0.html", { method: "POST" });`, + expected: { + stacktraceAvailable: true, + lastFrame: { + filename: + "https://example.com/browser/devtools/shared/resources/tests/network_document.html", + lineNumber: 1, + columnNumber: 40, + functionName: "triggerRequest", + asyncCause: null, + }, + }, +}; + +add_task(async function() { + info("Test network stacktraces events"); + const tab = await addTab(TEST_URI); + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const networkEvents = new Map(); + const stackTraces = new Map(); + + function onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE + ) { + ok( + !networkEvents.has(resource.resourceId), + "The network event does not exist" + ); + + is( + resource.stacktraceAvailable, + REQUEST_STUB.expected.stacktraceAvailable, + "The stacktrace is available" + ); + is( + JSON.stringify(resource.lastFrame), + JSON.stringify(REQUEST_STUB.expected.lastFrame), + "The last frame of the stacktrace is available" + ); + + stackTraces.set(resource.resourceId, true); + return; + } + + if (resource.resourceType === ResourceWatcher.TYPES.NETWORK_EVENT) { + ok( + stackTraces.has(resource.stacktraceResourceId), + "The stack trace does exists" + ); + + networkEvents.set(resource.resourceId, true); + } + } + } + + function onResourceUpdated() {} + + await resourceWatcher.watchResources( + [ + ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE, + ResourceWatcher.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]); + + resourceWatcher.unwatchResources( + [ + ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE, + ResourceWatcher.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await targetList.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/resources/tests/browser_resources_network_events.js b/devtools/shared/resources/tests/browser_resources_network_events.js new file mode 100644 index 0000000000..81113779bf --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_network_events.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around NETWORK_EVENT + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const EXAMPLE_DOMAIN = "https://example.com/"; +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +add_task(async function() { + info("Test network events"); + await testNetworkEventResourcesWithExistingResources(); + await testNetworkEventResourcesWithoutExistingResources(); +}); + +async function testNetworkEventResourcesWithExistingResources() { + info(`Tests for network event resources with the existing resources`); + await testNetworkEventResources({ + ignoreExistingResources: false, + // 1 available event fired, for the existing resource in the cache. + // 1 available event fired, when live request is created. + totalExpectedOnAvailableCounts: 2, + // 1 update events fired, when live request is updated. + totalExpectedOnUpdatedCounts: 1, + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}cached_post.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "POST", + }, + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResourcesWithoutExistingResources() { + info(`Tests for network event resources without the existing resources`); + await testNetworkEventResources({ + ignoreExistingResources: true, + // 1 available event fired, when live request is created. + totalExpectedOnAvailableCounts: 1, + // 1 update events fired, when live request is updated. + totalExpectedOnUpdatedCounts: 1, + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResources(options) { + const tab = await addTab(TEST_URI); + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + `Trigger some network requests *before* calling ResourceWatcher.watchResources + in order to assert the behavior of already existing network events.` + ); + + let onResourceAvailable = () => {}; + let onResourceUpdated = () => {}; + + // Lets make sure there is already a network event resource in the cache. + const waitOnRequestForResourceWatcherCache = new Promise(resolve => { + onResourceAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + } + }; + + onResourceUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + resolve(); + } + }; + + resourceWatcher + .watchResources([ResourceWatcher.TYPES.NETWORK_EVENT], { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + }) + .then(() => { + // We can only trigger the requests once `watchResources` settles, otherwise the + // thread might be paused. + triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]); + }); + }); + + await waitOnRequestForResourceWatcherCache; + + const actualResourcesOnAvailable = {}; + const actualResourcesOnUpdated = {}; + + let { + totalExpectedOnAvailableCounts, + totalExpectedOnUpdatedCounts, + expectedResourcesOnAvailable, + expectedResourcesOnUpdated, + + ignoreExistingResources, + } = options; + + const waitForAllExpectedOnAvailableEvents = waitUntil( + () => totalExpectedOnAvailableCounts == 0 + ); + const waitForAllExpectedOnUpdatedEvents = waitUntil( + () => totalExpectedOnUpdatedCounts == 0 + ); + + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + actualResourcesOnAvailable[resource.url] = { + resourceId: resource.resourceId, + resourceType: resource.resourceType, + method: resource.method, + }; + totalExpectedOnAvailableCounts--; + } + }; + + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + actualResourcesOnUpdated[resource.url] = { + resourceId: resource.resourceId, + resourceType: resource.resourceType, + method: resource.method, + }; + totalExpectedOnUpdatedCounts--; + } + }; + + await resourceWatcher.watchResources([ResourceWatcher.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + info( + `Trigger the rest of the requests *after* calling ResourceWatcher.watchResources + in order to assert the behavior of live network events.` + ); + await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]); + + await Promise.all([ + waitForAllExpectedOnAvailableEvents, + waitForAllExpectedOnUpdatedEvents, + ]); + + info("Check the resources on available"); + is( + Object.keys(actualResourcesOnAvailable).length, + Object.keys(expectedResourcesOnAvailable).length, + "Got the expected number of network events fired onAvailable" + ); + + // assert that the resourceId for the the available and updated events match + is( + actualResourcesOnAvailable[`${EXAMPLE_DOMAIN}live_get.html`].resourceId, + actualResourcesOnUpdated[`${EXAMPLE_DOMAIN}live_get.html`].resourceId, + "The resource id's are the same" + ); + + // assert the resources emitted when the network event is created + for (const key in expectedResourcesOnAvailable) { + const expected = expectedResourcesOnAvailable[key]; + const actual = actualResourcesOnAvailable[key]; + assertResources(actual, expected); + } + + info("Check the resources on updated"); + + is( + Object.keys(actualResourcesOnUpdated).length, + Object.keys(expectedResourcesOnUpdated).length, + "Got the expected number of network events fired onUpdated" + ); + + // assert the resources emitted when the network event is updated + for (const key in expectedResourcesOnUpdated) { + const expected = expectedResourcesOnUpdated[key]; + const actual = actualResourcesOnUpdated[key]; + assertResources(actual, expected); + } + + await resourceWatcher.unwatchResources( + [ResourceWatcher.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources, + } + ); + + await resourceWatcher.unwatchResources( + [ResourceWatcher.TYPES.NETWORK_EVENT], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + await targetList.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + expected.resourceType, + "The resource type is correct" + ); + is(actual.method, expected.method, "The method is correct"); +} + +const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`; +const liveRequest = `await fetch("/live_get.html", { method: "GET" });`; diff --git a/devtools/shared/resources/tests/browser_resources_platform_messages.js b/devtools/shared/resources/tests/browser_resources_platform_messages.js new file mode 100644 index 0000000000..324b92f771 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_platform_messages.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around PLATFORM_MESSAGE +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testPlatformMessagesResources(); + await testPlatformMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testPlatformMessagesResources() { + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + const expectedMessages = [ + "This is a cached message", + "This is another cached message", + "This is a live message", + "This is another live message", + ]; + const receivedMessages = []; + + info( + "Log some messages *before* calling ResourceWatcher.watchResources in order to assert the behavior of already existing messages." + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + is( + resource.targetFront, + targetList.targetFront, + "The targetFront property is the expected one" + ); + + receivedMessages.push(resource.message); + is( + resource.message, + expectedMessages[receivedMessages.length - 1], + `Received the expected ยซ${resource.message}ยป message, in the expected order` + ); + + ok( + resource.timeStamp.toString().match(/^\d+$/), + "The resource has a timeStamp property" + ); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.PLATFORM_MESSAGE], + { + onAvailable, + } + ); + + info( + "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages" + ); + Services.console.logStringMessage(expectedMessages[2]); + Services.console.logStringMessage(expectedMessages[3]); + + info("Waiting for all expected messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected messages were received"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +async function testPlatformMessagesResourcesWithIgnoreExistingResources() { + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + info( + "Check whether onAvailable will not be called with existing platform messages" + ); + const expectedMessages = ["This is 1st message", "This is 2nd message"]; + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + const availableResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.PLATFORM_MESSAGE], + { + onAvailable: resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + availableResources.push(resource); + } + }, + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing platform messages" + ); + + info( + "Check whether onAvailable will be called with the future platform messages" + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const { message } = availableResources[i]; + const expected = expectedMessages[i]; + is(message, expected, `Message[${i}] is correct`); + } + + Services.console.reset(); + await targetList.destroy(); + await client.close(); +} diff --git a/devtools/shared/resources/tests/browser_resources_root_node.js b/devtools/shared/resources/tests/browser_resources_root_node.js new file mode 100644 index 0000000000..44c9b7347f --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_root_node.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around ROOT_NODE + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * The original test still asserts some scenarios using several watchRootNode + * call sites, which is not something we intend to support at the moment in the + * resource watcher. + * + * Otherwise this test checks the basic behavior of the resource when reloading + * an empty page. + */ +add_task(async function() { + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + let onAvailableCounter = 0; + const onAvailable = resources => (onAvailableCounter += resources.length); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + await waitUntil(() => onAvailableCounter === 1); + is(onAvailableCounter, 1, "onAvailable has been called 1 time"); + + info("Reload the selected browser"); + browser.reload(); + + info( + "Wait until the watchResources([ROOT_NODE], ...) callback has been called" + ); + await waitUntil(() => onAvailableCounter === 2); + + is(onAvailableCounter, 2, "onAvailable has been called 2 times"); + + info("Call unwatchResources([ROOT_NODE], ...) for the onAvailable callback"); + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Reload the selected browser"); + const reloaded = BrowserTestUtils.browserLoaded(browser); + browser.reload(); + await reloaded; + + is( + onAvailableCounter, + 2, + "onAvailable was not called after calling unwatchResources" + ); + + // Cleanup + targetList.destroy(); + await client.close(); +}); + +/** + * Test that the watchRootNode API provides the expected node fronts. + */ +add_task(async function testRootNodeFrontIsCorrect() { + const tab = await addTab("data:text/html,<div id=div1>"); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + + let rootNodeResolve; + let rootNodePromise = new Promise(r => (rootNodeResolve = r)); + const onAvailable = ([rootNodeFront]) => rootNodeResolve(rootNodeFront); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + const root1 = await rootNodePromise; + ok(!!root1, "onAvailable has been called with a valid argument"); + is( + root1.resourceType, + ResourceWatcher.TYPES.ROOT_NODE, + "The resource has the expected type" + ); + + info("Check we can query an expected node under the retrieved root"); + const div1 = await root1.walkerFront.querySelector(root1, "div"); + is(div1.getAttribute("id"), "div1", "Correct root node retrieved"); + + info("Reload the selected browser"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + browser.reload(); + + const root2 = await rootNodePromise; + ok( + root1 !== root2, + "onAvailable has been called with a different node front after reload" + ); + + info("Navigate to another URL"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + BrowserTestUtils.loadURI(browser, `data:text/html,<div id=div3>`); + const root3 = await rootNodePromise; + info("Check we can query an expected node under the retrieved root"); + const div3 = await root3.walkerFront.querySelector(root3, "div"); + is(div3.getAttribute("id"), "div3", "Correct root node retrieved"); + + // Cleanup + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_several_resources.js b/devtools/shared/resources/tests/browser_resources_several_resources.js new file mode 100644 index 0000000000..b197eddf54 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_several_resources.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * Check that the resource watcher is still properly watching for new targets + * after unwatching one resource, if there is still another watched resource. + */ +add_task(async function() { + // We will create a main process target list here in order to monitor + // resources from new tabs as they get created. + // devtools.browsertoolbox.fission should be true to monitor resources from + // remote browsers & frames. + await pushPref("devtools.browsertoolbox.fission", true); + + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + const { CONSOLE_MESSAGE, ROOT_NODE } = ResourceWatcher.TYPES; + + // We are only interested in console messages as a resource, the ROOT_NODE one + // is here to test the ResourceWatcher::unwatchResources API with several resources. + const receivedMessages = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + receivedMessages.push(resource); + } + } + }; + + info("Call watchResources([CONSOLE_MESSAGE, ROOT_NODE], ...)"); + await resourceWatcher.watchResources([CONSOLE_MESSAGE, ROOT_NODE], { + onAvailable, + }); + + info("Use console.log in the content page"); + logInTab(tab, "test from data-url"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the data-url tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test from data-url" + ) + ); + + // Check that the resource watcher captures resources from new targets. + info("Open a first tab on the example.com domain"); + const comTab = await addTab( + "http://example.com/document-builder.sjs?html=com" + ); + info("Use console.log in the example.com page"); + logInTab(comTab, "test-from-example-com"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.com tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-com" + ) + ); + + info("Stop watching ROOT_NODE resources"); + await resourceWatcher.unwatchResources([ROOT_NODE], { onAvailable }); + + // Check that messages from new targets are still captured after calling + // unwatch for another resource. + info("Open a second tab on the example.net domain"); + const netTab = await addTab( + "http://example.net/document-builder.sjs?html=net" + ); + info("Use console.log in the example.net page"); + logInTab(netTab, "test-from-example-net"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.net tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-net" + ) + ); + + info("Stop watching CONSOLE_MESSAGE resources"); + await resourceWatcher.unwatchResources([CONSOLE_MESSAGE], { onAvailable }); + await logInTab(tab, "test-again"); + + // We don't have a specific event to wait for here, so allow some time for + // the message to be received. + await wait(1000); + + is( + receivedMessages.find( + resource => resource.message.arguments[0] === "test-again" + ), + undefined, + "The resource watcher should not watch CONSOLE_MESSAGE anymore" + ); + + // Cleanup + targetList.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function(_message) { + content.console.log(_message); + }); +} diff --git a/devtools/shared/resources/tests/browser_resources_sources.js b/devtools/shared/resources/tests/browser_resources_sources.js new file mode 100644 index 0000000000..68f533d713 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_sources.js @@ -0,0 +1,198 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around SOURCE. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URL = URL_ROOT_SSL + "sources.html"; + +add_task(async function() { + const tab = await addTab(TEST_URL); + + const htmlRequest = await fetch(TEST_URL); + const htmlContent = await htmlRequest.text(); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + // Force the target list to cover workers + targetList.listenForWorkers = true; + targetList.listenForServiceWorkers = true; + await targetList.startListening(); + + const targets = []; + await targetList.watchTargets(targetList.ALL_TYPES, async function({ + targetFront, + }) { + targets.push(targetFront); + }); + is(targets.length, 3, "Got expected number of targets"); + + info("Check already available resources"); + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + const expectedExistingResources = [ + { + description: "independent js file", + sourceForm: { + introductionType: "scriptElement", + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/sources.js", + url: + "https://example.com/browser/devtools/shared/resources/tests/sources.js", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction scriptSource() {}\n", + }, + }, + { + description: "eval", + sourceForm: { + introductionType: "eval", + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/sources.html", + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "this.global = function evalFunction() {}", + }, + }, + { + description: "inline JS", + sourceForm: { + introductionType: "scriptElement", + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/sources.html", + url: + "https://example.com/browser/devtools/shared/resources/tests/sources.html", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/html", + source: htmlContent, + }, + }, + { + description: "worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/worker-sources.js", + url: + "https://example.com/browser/devtools/shared/resources/tests/worker-sources.js", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction workerSource() {}\n", + }, + }, + { + description: "service worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/service-worker-sources.js", + url: + "https://example.com/browser/devtools/shared/resources/tests/service-worker-sources.js", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n", + }, + }, + ]; + await assertResources(availableResources, expectedExistingResources); + + await targetList.stopListening(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +async function assertResources(resources, expected) { + is( + resources.length, + expected.length, + "Length of existing resources is correct at initial" + ); + for (let i = 0; i < resources.length; i++) { + await assertResource(resources[i], expected); + } +} + +async function assertResource(source, expected) { + info(`Checking resource "#${expected.description}"`); + + is( + source.resourceType, + ResourceWatcher.TYPES.SOURCE, + "Resource type is correct" + ); + + const threadFront = await source.targetFront.getFront("thread"); + // `source` is SourceActor's form() + // so try to instantiate the related SourceFront: + const sourceFront = threadFront.source(source); + // then fetch source content + const sourceContent = await sourceFront.source(); + + // Order of sources is random, so we have to find the best expected resource. + // The only unique attribute is the JS Source text content. + const matchingExpected = expected.find(res => { + return res.sourceContent.source == sourceContent.source; + }); + ok( + matchingExpected, + `This source was expected with source content being "${sourceContent.source}"` + ); + assertObject( + sourceContent, + matchingExpected.sourceContent, + matchingExpected.description + ); + + assertObject( + source, + matchingExpected.sourceForm, + matchingExpected.description + ); +} + +function assertObject(object, expected, description) { + for (const field in expected) { + is( + object[field], + expected[field], + `The value of ${field} is correct for "#${description}"` + ); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_stylesheets.js b/devtools/shared/resources/tests/browser_resources_stylesheets.js new file mode 100644 index 0000000000..79735cae0e --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_stylesheets.js @@ -0,0 +1,506 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around STYLESHEET. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html"; + +const EXISTING_RESOURCES = [ + { + styleText: "body { color: lime; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, + { + styleText: "body { margin: 1px; }", + href: + "https://example.com/browser/devtools/shared/resources/tests/style_document.css", + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, + { + styleText: "body { background-color: pink; }", + href: null, + nodeHref: + "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, + { + styleText: "body { padding: 1px; }", + href: + "https://example.org/browser/devtools/shared/resources/tests/style_iframe.css", + nodeHref: + "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, +]; + +const ADDITIONAL_RESOURCE = { + styleText: + "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: false, + disabled: false, + ruleCount: 3, + mediaRules: [ + { + conditionText: "all", + mediaText: "all", + matches: true, + line: 1, + column: 1, + }, + { + conditionText: "print", + mediaText: "print", + matches: false, + line: 1, + column: 37, + }, + ], +}; + +const ADDITIONAL_FROM_ACTOR_RESOURCE = { + styleText: "body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: true, + disabled: false, + ruleCount: 1, + mediaRules: [], +}; + +add_task(async function() { + await pushPref("devtools.testing.enableServerWatcherSupport", false); + await testResourceAvailableFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); + + await pushPref("devtools.testing.enableServerWatcherSupport", true); + await testResourceAvailableFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); +}); + +async function testResourceAvailableFeature() { + info("Check resource available feature of the ResourceWatcher"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check whether ResourceWatcher gets existing stylesheet"); + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + // We can not expect the resources to always be forwarded in the same order. + // See intermittent Bug 1655016. + const expectedResource = findMatchingExpectedResource(availableResource); + ok(expectedResource, "Found a matching expected resource for the resource"); + await assertResource(availableResource, expectedResource); + } + + info("Check whether ResourceWatcher gets additonal stylesheet"); + await ContentTask.spawn( + tab.linkedBrowser, + ADDITIONAL_RESOURCE.styleText, + text => { + const document = content.document; + const stylesheet = document.createElement("style"); + stylesheet.textContent = text; + document.body.appendChild(stylesheet); + } + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 1 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_RESOURCE + ); + + info( + "Check whether ResourceWatcher gets additonal stylesheet which is added by DevTool" + ); + const styleSheetsFront = await targetList.targetFront.getFront("stylesheets"); + await styleSheetsFront.addStyleSheet( + ADDITIONAL_FROM_ACTOR_RESOURCE.styleText + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 2 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_FROM_ACTOR_RESOURCE + ); + + await targetList.destroy(); + await client.close(); +} + +async function testResourceUpdateFeature() { + info("Check resource update feature of the ResourceWatcher"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Check toggleDisabled function"); + const resource = availableResources[0]; + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.toggleDisabled(resource.resourceId); + await waitUntil(() => updates.length === 1); + + // Check the content of the update object. + assertUpdate(updates[0].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[0].update.resourceUpdates.disabled, + true, + "resourceUpdates is correct" + ); + + // Check whether the cached resource is updated correctly. + is( + updates[0].resource.disabled, + true, + "cached resource is updated correctly" + ); + + // Check whether the actual stylesheet is updated correctly. + const styleSheetDisabled = await ContentTask.spawn( + tab.linkedBrowser, + null, + () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + return stylesheet.disabled; + } + ); + is(styleSheetDisabled, true, "actual stylesheet is is updated correctly"); + + info("Check update function"); + const expectedMediaRules = [ + { + conditionText: "screen", + mediaText: "screen", + matches: true, + }, + { + conditionText: "print", + mediaText: "print", + matches: false, + }, + ]; + + const updateCause = "updated-by-test"; + await styleSheetsFront.update( + resource.resourceId, + "@media screen { color: red; } @media print { color: green; } body { color: cyan; }", + false, + updateCause + ); + await waitUntil(() => updates.length === 4); + + assertUpdate(updates[1].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[1].update.resourceUpdates.ruleCount, + 3, + "resourceUpdates is correct" + ); + is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly"); + + assertUpdate(updates[2].update, { + resourceId: resource.resourceId, + updateType: "style-applied", + event: { + cause: updateCause, + }, + }); + is( + updates[2].update.resourceUpdates, + undefined, + "resourceUpdates is correct" + ); + + assertUpdate(updates[3].update, { + resourceId: resource.resourceId, + updateType: "media-rules-changed", + }); + assertMediaRules( + updates[3].update.resourceUpdates.mediaRules, + expectedMediaRules + ); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + + is( + styleSheetResult.ruleCount, + 3, + "ruleCount of actual stylesheet is updated correctly" + ); + assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules); + + await targetList.destroy(); + await client.close(); +} + +async function testNestedResourceUpdateFeature() { + info("Check nested resource update feature of the ResourceWatcher"); + + const tab = await addTab(STYLE_TEST_URL); + + const { + outerWidth: originalWindowWidth, + outerHeight: originalWindowHeight, + } = tab.ownerGlobal; + + registerCleanupFunction(() => { + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Apply new media query"); + // In order to avoid applying the media query (min-height: 400px). + tab.ownerGlobal.resizeTo(originalWindowWidth, 300); + + const resource = availableResources[0]; + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.update( + resource.resourceId, + "@media (min-height: 400px) { color: red; }", + false + ); + await waitUntil(() => updates.length === 3); + is(resource.mediaRules[0].matches, false, "Media query is not matched yet"); + + info("Change window size to fire matches-change event"); + tab.ownerGlobal.resizeTo(originalWindowWidth, 500); + await waitUntil(() => updates.length === 4); + + // Check the update content. + const isServerWatcher = Services.prefs.getBoolPref( + "devtools.testing.enableServerWatcherSupport" + ); + const targetUpdate = updates[3]; + assertUpdate(targetUpdate.update, { + resourceId: resource.resourceId, + updateType: "matches-change", + }); + ok(resource === targetUpdate.resource, "Update object has the same resource"); + + if (isServerWatcher) { + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["mediaRules", 0, "matches"]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value, + true, + "value of nestedResourceUpdates is correct" + ); + } else { + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["mediaRules", 0]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value.matches, + true, + "value of nestedResourceUpdates is correct" + ); + } + + // Check the resource. + const expectedMediaRules = [ + { + conditionText: "(min-height: 400px)", + mediaText: "(min-height: 400px)", + matches: true, + }, + ]; + + assertMediaRules(targetUpdate.resource.mediaRules, expectedMediaRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + is( + styleSheetResult.ruleCount, + 1, + "ruleCount of actual stylesheet is updated correctly" + ); + assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules); + + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + + await targetList.destroy(); + await client.close(); +} + +function findMatchingExpectedResource(resource) { + return EXISTING_RESOURCES.find( + expected => + resource.href === expected.href && resource.nodeHref === expected.nodeHref + ); +} + +async function getStyleSheetResult(tab) { + const result = await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + const ruleCount = stylesheet.cssRules.length; + + const mediaRules = []; + for (const rule of stylesheet.cssRules) { + if (!rule.media) { + continue; + } + + let matches = false; + try { + const mql = content.matchMedia(rule.media.mediaText); + matches = mql.matches; + } catch (e) { + // Ignored + } + + mediaRules.push({ + mediaText: rule.media.mediaText, + conditionText: rule.conditionText, + matches, + }); + } + + return { ruleCount, mediaRules }; + }); + + return result; +} + +function assertMediaRules(mediaRules, expected) { + is(mediaRules.length, expected.length, "Length of the mediaRules is correct"); + + for (let i = 0; i < mediaRules.length; i++) { + is( + mediaRules[i].conditionText, + expected[i].conditionText, + "conditionText is correct" + ); + is(mediaRules[i].mediaText, expected[i].mediaText, "mediaText is correct"); + is(mediaRules[i].matches, expected[i].matches, "matches is correct"); + + if (expected[i].line !== undefined) { + is(mediaRules[i].line, expected[i].line, "line is correct"); + } + + if (expected[i].column !== undefined) { + is(mediaRules[i].column, expected[i].column, "column is correct"); + } + } +} + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceWatcher.TYPES.STYLESHEET, + "Resource type is correct" + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + const styleText = ( + await styleSheetsFront.getText(resource.resourceId) + ).str.trim(); + is(styleText, expected.styleText, "Style text is correct"); + is(resource.href, expected.href, "href is correct"); + is(resource.nodeHref, expected.nodeHref, "nodeHref is correct"); + is(resource.isNew, expected.isNew, "isNew is correct"); + is(resource.disabled, expected.disabled, "disabled is correct"); + is(resource.ruleCount, expected.ruleCount, "ruleCount is correct"); + assertMediaRules(resource.mediaRules, expected.mediaRules); +} + +function assertUpdate(update, expected) { + is( + update.resourceType, + ResourceWatcher.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(update.resourceId, expected.resourceId, "resourceId is correct"); + is(update.updateType, expected.updateType, "updateType is correct"); + if (expected.event?.cause) { + is(update.event?.cause, expected.event.cause, "cause is correct"); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_target_destroy.js b/devtools/shared/resources/tests/browser_resources_target_destroy.js new file mode 100644 index 0000000000..9e993ef8cc --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_target_destroy.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the server ResourceWatcher are destroyed when the associated target actors +// are destroyed. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + const tab = await addTab("data:text/html,Test"); + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + // Start watching for console messages. We don't care about messages here, only the + // registration/destroy mechanism, so we make onAvailable a no-op function. + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info( + "Spawn a content task in order to be able to manipulate actors and resource watchers directly" + ); + await ContentTask.spawn(tab.linkedBrowser, [], function() { + const { require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" + ); + const { + TargetActorRegistry, + } = require("devtools/server/actors/targets/target-actor-registry.jsm"); + const { + getResourceWatcher, + TYPES, + } = require("devtools/server/actors/resources/index"); + + // Retrieve the target actor instance and its watcher for console messages + const targetActor = TargetActorRegistry.getTargetActor( + content.browsingContext.browserId + ); + const watcher = getResourceWatcher(targetActor, TYPES.CONSOLE_MESSAGE); + + // Storing the target actor in the global so we can retrieve it later, even if it + // was destroyed + content._testTargetActor = targetActor; + + is(!!watcher, true, "The console message resource watcher was created"); + }); + + info("Close the client, which will destroy the target"); + targetList.destroy(); + await client.close(); + + info( + "Spawn a content task in order to run some assertions on actors and resource watchers directly" + ); + await ContentTask.spawn(tab.linkedBrowser, [], function() { + const { require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" + ); + const { + getResourceWatcher, + TYPES, + } = require("devtools/server/actors/resources/index"); + + ok( + content._testTargetActor && !content._testTargetActor.actorID, + "The target was destroyed when the client was closed" + ); + + // Retrieve the console message resource watcher + const watcher = getResourceWatcher( + content._testTargetActor, + TYPES.CONSOLE_MESSAGE + ); + + is( + !!watcher, + false, + "The console message resource watcher isn't registered anymore after the target was destroyed" + ); + + // Cleanup work variable + delete content._testTargetActor; + }); +}); diff --git a/devtools/shared/resources/tests/browser_resources_target_resources_race.js b/devtools/shared/resources/tests/browser_resources_target_resources_race.js new file mode 100644 index 0000000000..c7e18f8ce1 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_target_resources_race.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * Test initial target resources are correctly retrieved even when several calls + * to watchResources are made simultaneously. + * + * This checks a race condition which occurred when calling watchResources + * simultaneously. This made the "second" call to watchResources miss existing + * resources (in case those are emitted from the target instead of the watcher). + * See Bug 1663896. + */ +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + const expectedPlatformMessage = "expectedMessage"; + + info("Log a message *before* calling ResourceWatcher.watchResources"); + Services.console.logStringMessage(expectedPlatformMessage); + + info("Call watchResources from 2 separate call sites consecutively"); + + // Empty onAvailable callback for CSS MESSAGES, we only want to check that + // the second resource we watch correctly provides existing resources. + const onCssMessageAvailable = resources => {}; + + // First call to watchResources. + // We do not await on `watchPromise1` here, in order to simulate simultaneous + // calls to watchResources (which could come from 2 separate modules in a real + // scenario). + const initialWatchPromise = resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CSS_MESSAGE], + { + onAvailable: onCssMessageAvailable, + } + ); + + // `waitForNextResource` will trigger another call to `watchResources`. + const onMessageReceived = waitForNextResource( + resourceWatcher, + ResourceWatcher.TYPES.PLATFORM_MESSAGE, + { + ignoreExistingResources: false, + predicate: r => r.message === expectedPlatformMessage, + } + ); + + info("Waiting for the expected message to be received"); + await onMessageReceived; + ok(true, "All the expected messages were received"); + + info("Wait for the other watchResources promise to finish"); + await initialWatchPromise; + + // Unwatch all resources. + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CSS_MESSAGE], { + onAvailable: onCssMessageAvailable, + }); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_target_switching.js b/devtools/shared/resources/tests/browser_resources_target_switching.js new file mode 100644 index 0000000000..1388970dcd --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_target_switching.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behavior of ResourceWatcher when the top level target changes + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); +const { CONSOLE_MESSAGE, SOURCE } = ResourceWatcher.TYPES; + +const TEST_URI = + "data:text/html;charset=utf-8,<script>console.log('foo');</script>"; + +add_task(async function() { + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceWatcher.getAllResources(CONSOLE_MESSAGE).length, + 0, + "There is no resources before calling watchResources" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => { + // Ignore message coming from shared worker started by previous tests and + // logging late a console message + resources + .filter(r => { + return !r.message.arguments[0].startsWith("[WORKER] started"); + }) + .map(r => availableResources.push(r)); + }; + await resourceWatcher.watchResources([CONSOLE_MESSAGE], { onAvailable }); + + is(availableResources.length, 1, "Got the page message"); + is( + availableResources[0].message.arguments[0], + "foo", + "Got the expected page message" + ); + + // Register another listener before unregistering the console listener + // otherwise the resource watcher stop watching for targets + const onSourceAvailable = () => {}; + await resourceWatcher.watchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + info( + "Unregister the console listener and check that we no longer listen for console messages" + ); + resourceWatcher.unwatchResources([CONSOLE_MESSAGE], { + onAvailable, + }); + + let onSwitched = targetList.once("switched-target"); + info("Navigate to another process"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, "about:robots"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "about:robots doesn't fire any new message, so we should have a new one" + ); + + info("Navigate back to data: URI"); + onSwitched = targetList.once("switched-target"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, TEST_URI); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "the data:URI fired a message, but we are no longer listening to it, so no new one should be notified" + ); + is( + resourceWatcher.getAllResources(CONSOLE_MESSAGE).length, + 0, + "As we are no longer listening to CONSOLE message, we should not collect any" + ); + + resourceWatcher.unwatchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + await targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_websocket.js b/devtools/shared/resources/tests/browser_resources_websocket.js new file mode 100644 index 0000000000..2437836d79 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_websocket.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around WEBSOCKET. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URL = URL_ROOT + "websocket_frontend.html"; +const IS_NUMBER = "IS_NUMBER"; + +add_task(async function() { + const tab = await addTab(TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check available resources at initial"); + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.WEBSOCKET], { + onAvailable: resources => availableResources.push(...resources), + }); + is( + availableResources.length, + 0, + "Length of existing resources is correct at initial" + ); + + info("Check resource of opening websocket"); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + await content.wrappedJSObject.openConnection(); + }); + await waitUntil(() => availableResources.length === 1); + const httpChannelId = availableResources[0].httpChannelId; + ok(httpChannelId, "httpChannelId is present in the resource"); + assertResource(availableResources[0], { + wsMessageType: "webSocketOpened", + effectiveURI: + "ws://mochi.test:8888/browser/devtools/shared/resources/tests/websocket_backend", + extensions: "permessage-deflate", + protocols: "", + }); + + info("Check resource of sending/receiving the data via websocket"); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + await content.wrappedJSObject.sendData("test"); + }); + await waitUntil(() => availableResources.length === 3); + assertResource(availableResources[1], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "test", + }, + }); + assertResource(availableResources[2], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "test", + }, + }); + + info("Check resource of closing websocket"); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + await content.wrappedJSObject.closeConnection(); + }); + await waitUntil(() => availableResources.length === 6); + assertResource(availableResources[3], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "", + }, + }); + assertResource(availableResources[4], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "", + }, + }); + assertResource(availableResources[5], { + wsMessageType: "webSocketClosed", + httpChannelId, + code: IS_NUMBER, + reason: "", + wasClean: true, + }); + + info("Check existing resources"); + const existingResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.WEBSOCKET], { + onAvailable: resources => existingResources.push(...resources), + }); + is( + availableResources.length, + existingResources.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + const existingResource = existingResources[i]; + ok( + availableResource === existingResource, + `The ${i}th resource is correct` + ); + } + + await targetList.destroy(); + await client.close(); +}); + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceWatcher.TYPES.WEBSOCKET, + "Resource type is correct" + ); + + assertObject(resource, expected); +} + +function assertObject(object, expected) { + for (const field in expected) { + if (typeof expected[field] === "object") { + assertObject(object[field], expected[field]); + } else if (expected[field] === IS_NUMBER) { + ok(!isNaN(object[field]), `The value of ${field} is number`); + } else { + is(object[field], expected[field], `The value of ${field} is correct`); + } + } +} diff --git a/devtools/shared/resources/tests/browser_target_list_browser_workers.js b/devtools/shared/resources/tests/browser_target_list_browser_workers.js new file mode 100644 index 0000000000..fa85708165 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_browser_workers.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around workers + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + WORKER_FILE; +const SERVICE_WORKER_URL = URL_ROOT_SSL + "test_service_worker.js"; + +add_task(async function() { + // Enabled fission's pref as the TargetList is almost disabled without it + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + const tab = await addTab(FISSION_TEST_URL); + + info("Test TargetList against workers via the parent process target"); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + await targetList.startListening(); + + // Very naive sanity check against getAllTargets([workerType]) + info("Check that getAllTargets returned the expected targets"); + const workers = await targetList.getAllTargets([TYPES.WORKER]); + const hasWorker = workers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#simple-worker"; + }); + ok(hasWorker, "retrieve the target for the worker"); + + const sharedWorkers = await targetList.getAllTargets([TYPES.SHARED_WORKER]); + const hasSharedWorker = sharedWorkers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#shared-worker"; + }); + ok(hasSharedWorker, "retrieve the target for the shared worker"); + + const serviceWorkers = await targetList.getAllTargets([TYPES.SERVICE_WORKER]); + const hasServiceWorker = serviceWorkers.find(workerTarget => { + return workerTarget.url == SERVICE_WORKER_URL; + }); + ok(hasServiceWorker, "retrieve the target for the service worker"); + + info( + "Check that calling getAllTargets again return the same target instances" + ); + const workers2 = await targetList.getAllTargets([TYPES.WORKER]); + const sharedWorkers2 = await targetList.getAllTargets([TYPES.SHARED_WORKER]); + const serviceWorkers2 = await targetList.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is(workers2.length, workers.length, "retrieved the same number of workers"); + is( + sharedWorkers2.length, + sharedWorkers.length, + "retrieved the same number of shared workers" + ); + is( + serviceWorkers2.length, + serviceWorkers.length, + "retrieved the same number of service workers" + ); + + workers.sort(sortFronts); + workers2.sort(sortFronts); + sharedWorkers.sort(sortFronts); + sharedWorkers2.sort(sortFronts); + serviceWorkers.sort(sortFronts); + serviceWorkers2.sort(sortFronts); + + for (let i = 0; i < workers.length; i++) { + is(workers[i], workers2[i], `worker ${i} targets are the same`); + } + for (let i = 0; i < sharedWorkers2.length; i++) { + is( + sharedWorkers[i], + sharedWorkers2[i], + `shared worker ${i} targets are the same` + ); + } + for (let i = 0; i < serviceWorkers2.length; i++) { + is( + serviceWorkers[i], + serviceWorkers2[i], + `service worker ${i} targets are the same` + ); + } + + info( + "Check that watchTargets will call the create callback for all existing workers" + ); + const targets = []; + const onAvailable = async ({ targetFront }) => { + ok( + targetFront.targetType === TYPES.WORKER || + targetFront.targetType === TYPES.SHARED_WORKER || + targetFront.targetType === TYPES.SERVICE_WORKER, + "We are only notified about worker targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetList.watchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable + ); + is( + targets.length, + workers.length + sharedWorkers.length + serviceWorkers.length, + "retrieved the same number of workers via watchTargets" + ); + + targets.sort(sortFronts); + const allWorkers = workers + .concat(sharedWorkers, serviceWorkers) + .sort(sortFronts); + + for (let i = 0; i < allWorkers.length; i++) { + is( + allWorkers[i], + targets[i], + `worker ${i} targets are the same via watchTargets` + ); + } + + targetList.unwatchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable + ); + + // Create a new worker and see if the worker target is reported + const onWorkerCreated = new Promise(resolve => { + const onAvailable2 = async ({ targetFront }) => { + if (targets.includes(targetFront)) { + return; + } + targetList.unwatchTargets([TYPES.WORKER], onAvailable2); + resolve(targetFront); + }; + targetList.watchTargets([TYPES.WORKER], onAvailable2); + }); + // eslint-disable-next-line no-unused-vars + const worker2 = new Worker(CHROME_WORKER_URL + "#second"); + info("Wait for the second worker to be created"); + const workerTarget = await onWorkerCreated; + + is( + workerTarget.url, + CHROME_WORKER_URL + "#second", + "This worker target is about the new worker" + ); + + const workers3 = await targetList.getAllTargets([TYPES.WORKER]); + const hasWorker2 = workers3.find( + ({ url }) => url == `${CHROME_WORKER_URL}#second` + ); + ok(hasWorker2, "retrieve the target for tab via getAllTargets"); + + targetList.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(client); + + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; +} diff --git a/devtools/shared/resources/tests/browser_target_list_frames.js b/devtools/shared/resources/tests/browser_target_list_frames.js new file mode 100644 index 0000000000..0549c67842 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_frames.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around frames + +const { TargetList } = require("devtools/shared/resources/target-list"); + +const FISSION_TEST_URL = URL_ROOT_SSL + "/fission_document.html"; + +add_task(async function() { + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.fission", true); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + // Test fetching the frames from the main process target + await testBrowserFrames(mainRoot); + + // Test fetching the frames from a tab target + await testTabFrames(mainRoot); + + await client.close(); +}); + +async function testBrowserFrames(mainRoot) { + info("Test TargetList against frames via the parent process target"); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + await targetList.startListening(); + + // Very naive sanity check against getAllTargets([frame]) + const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + const hasBrowserDocument = frames.find( + frameTarget => frameTarget.url == window.location.href + ); + ok(hasBrowserDocument, "retrieve the target for the browser document"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames2 = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + is(frames2.length, frames.length, "retrieved the same number of frames"); + + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + frames.sort(sortFronts); + frames2.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is(frames[i], frames2[i], `frame ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.FRAME], onAvailable); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + + frames.sort(sortFronts); + targets.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is( + frames[i], + targets[i], + `frame ${i} targets are the same via watchTargets` + ); + } + targetList.unwatchTargets([TargetList.TYPES.FRAME], onAvailable); + + /* NOT READY YET, need to implement frame listening + // Open a new tab and see if the frame target is reported by watchTargets and getAllTargets + const tab = await addTab(TEST_URL); + + is(targets.length, frames.length + 1, "Opening a tab reported a new frame"); + is(targets[targets.length - 1].url, TEST_URL, "This frame target is about the new tab"); + + const frames3 = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + const hasTabDocument = frames3.find(target => target.url == TEST_URL); + ok(hasTabDocument, "retrieve the target for tab via getAllTargets"); + */ + + targetList.destroy(); + await waitForAllTargetsToBeAttached(targetList); +} + +async function testTabFrames(mainRoot) { + info("Test TargetList against frames via a tab target"); + + // Create a TargetList for a given test tab + const tab = await addTab(FISSION_TEST_URL); + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + // When fission is enabled, we also get the remote example.org iframe. + const expectedFramesCount = isFissionEnabled() ? 2 : 1; + is( + frames.length, + expectedFramesCount, + "retrieved only the top level document" + ); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.FRAME], onAvailable); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + + for (const frame of frames) { + ok( + targets.find(t => t === frame), + "frame " + frame.actorID + " target is the same via watchTargets" + ); + } + targetList.unwatchTargets([TargetList.TYPES.FRAME], onAvailable); + + targetList.destroy(); + await waitForAllTargetsToBeAttached(targetList); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + BrowserTestUtils.removeTab(tab); +} diff --git a/devtools/shared/resources/tests/browser_target_list_getAllTargets.js b/devtools/shared/resources/tests/browser_target_list_getAllTargets.js new file mode 100644 index 0000000000..85822a7ede --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_getAllTargets.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API getAllTargets. + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { ALL_TYPES, TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +add_task(async function() { + // Enabled devtools.browsertoolbox.fission to listen to all target types. + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + const tab = await addTab(FISSION_TEST_URL); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + info("Create a target list for the main process target"); + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + await targetList.startListening(); + + info("Check getAllTargets will throw when providing invalid arguments"); + Assert.throws( + () => targetList.getAllTargets(), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + Assert.throws( + () => targetList.getAllTargets([]), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + info("Check getAllTargets returns consistent results with several types"); + const workerTargets = targetList.getAllTargets([TYPES.WORKER]); + const serviceWorkerTargets = targetList.getAllTargets([TYPES.SERVICE_WORKER]); + const sharedWorkerTargets = targetList.getAllTargets([TYPES.SHARED_WORKER]); + const processTargets = targetList.getAllTargets([TYPES.PROCESS]); + const frameTargets = targetList.getAllTargets([TYPES.FRAME]); + + const allWorkerTargetsReference = [ + ...workerTargets, + ...serviceWorkerTargets, + ...sharedWorkerTargets, + ]; + const allWorkerTargets = targetList.getAllTargets([ + TYPES.WORKER, + TYPES.SERVICE_WORKER, + TYPES.SHARED_WORKER, + ]); + + is( + allWorkerTargets.length, + allWorkerTargetsReference.length, + "getAllTargets([worker, service, shared]) returned the expected number of targets" + ); + + ok( + allWorkerTargets.every(t => allWorkerTargetsReference.includes(t)), + "getAllTargets([worker, service, shared]) returned the expected targets" + ); + + const allTargetsReference = [ + ...allWorkerTargets, + ...processTargets, + ...frameTargets, + ]; + const allTargets = targetList.getAllTargets(ALL_TYPES); + is( + allTargets.length, + allTargetsReference.length, + "getAllTargets(TYPES.ALL_TYPES) returned the expected number of targets" + ); + + ok( + allTargets.every(t => allTargetsReference.includes(t)), + "getAllTargets(TYPES.ALL_TYPES) returned the expected targets" + ); + + targetList.destroy(); + + // Wait for all the targets to be fully attached so we don't have pending requests. + await waitForAllTargetsToBeAttached(targetList); + + await client.close(); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); diff --git a/devtools/shared/resources/tests/browser_target_list_preffedoff.js b/devtools/shared/resources/tests/browser_target_list_preffedoff.js new file mode 100644 index 0000000000..244ffe6c53 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_preffedoff.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API when DevTools Fission preference is false + +const { TargetList } = require("devtools/shared/resources/target-list"); + +add_task(async function() { + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + const targetDescriptor = await mainRoot.getMainProcess(); + const mainProcess = await targetDescriptor.getTarget(); + + // Assert the limited behavior of this API with fission preffed off + await pushPref("devtools.browsertoolbox.fission", false); + + // Test with Main process targets as top level target + await testPreffedOffMainProcess(mainRoot, mainProcess); + + await client.close(); +}); + +async function testPreffedOffMainProcess(mainRoot, mainProcess) { + info( + "Test TargetList when devtools's fission pref is false, via the parent process target" + ); + + const targetList = new TargetList(mainRoot, mainProcess); + await targetList.startListening(); + + // The API should only report the top level target, + // i.e. the Main process target, which is considered as frame + // and not as process. + const processes = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes.length, + 0, + "We only get a frame target for the top level target" + ); + const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + is(frames.length, 1, "We get only one frame when preffed-off"); + is( + frames[0], + mainProcess, + "The target is the top level one via getAllTargets" + ); + + const processTargets = []; + const onProcessAvailable = ({ targetFront }) => { + processTargets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.PROCESS], onProcessAvailable); + is(processTargets.length, 0, "We get no process when preffed-off"); + targetList.unwatchTargets([TargetList.TYPES.PROCESS], onProcessAvailable); + + const frameTargets = []; + const onFrameAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront.isTopLevel, + "We are only notified about the top level target" + ); + frameTargets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.FRAME], onFrameAvailable); + is( + frameTargets.length, + 1, + "We get one frame via watchTargets when preffed-off" + ); + is( + frameTargets[0], + mainProcess, + "The target is the top level one via watchTargets" + ); + targetList.unwatchTargets([TargetList.TYPES.FRAME], onFrameAvailable); + + targetList.destroy(); +} diff --git a/devtools/shared/resources/tests/browser_target_list_processes.js b/devtools/shared/resources/tests/browser_target_list_processes.js new file mode 100644 index 0000000000..f3614b399d --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_processes.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around processes + +const { TargetList } = require("devtools/shared/resources/target-list"); + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function() { + // Enabled fission's pref as the TargetList is almost disabled without it + await pushPref("devtools.browsertoolbox.fission", true); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + const targetDescriptor = await mainRoot.getMainProcess(); + const mainProcess = await targetDescriptor.getTarget(); + + const targetList = new TargetList(mainRoot, mainProcess); + await targetList.startListening(); + + await testProcesses(targetList, mainProcess); + + await targetList.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetList + .getAllTargets(targetList.ALL_TYPES) + .map(t => t.attachAndInitThread(targetList)) + ); + + await client.close(); +}); + +async function testProcesses(targetList, target) { + info("Test TargetList against processes"); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + const processes = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes.length, + originalProcessesCount, + "Get a target for all content processes" + ); + + const processes2 = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes2.length, + originalProcessesCount, + "retrieved the same number of processes" + ); + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + processes.sort(sortFronts); + processes2.sort(sortFronts); + for (let i = 0; i < processes.length; i++) { + is(processes[i], processes2[i], `process ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = new Set(); + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroyed without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are never notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + is( + targets.size, + originalProcessesCount, + "retrieved the same number of processes via watchTargets" + ); + for (let i = 0; i < processes.length; i++) { + ok( + targets.has(processes[i]), + `process ${i} targets are the same via watchTargets` + ); + } + + const previousTargets = new Set(targets); + // Assert that onAvailable is called for processes created *after* the call to watchTargets + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetList.unwatchTargets([TargetList.TYPES.PROCESS], onAvailable2); + resolve(targetFront); + }; + targetList.watchTargets([TargetList.TYPES.PROCESS], onAvailable2); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the size of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroyed is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }; + targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + + // Ensure that getAllTargets still works after the call to unwatchTargets + const processes3 = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes3.length, + processCountAfterTabOpen - 1, + "getAllTargets reports a new target" + ); +} diff --git a/devtools/shared/resources/tests/browser_target_list_service_workers.js b/devtools/shared/resources/tests/browser_target_list_service_workers.js new file mode 100644 index 0000000000..7af04144b1 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_service_workers.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API for service workers in content tabs. + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; + +add_task(async function() { + // Enabled devtools.browsertoolbox.fission to listen to all target types. + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + const tab = await addTab(FISSION_TEST_URL); + + info("Create a target list for a tab target"); + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + // Enable Service Worker listening. + targetList.listenForServiceWorkers = true; + await targetList.startListening(); + + const serviceWorkerTargets = targetList.getAllTargets([TYPES.SERVICE_WORKER]); + is(serviceWorkerTargets.length, 1, "TargetList has 1 service worker target"); + + info("Check that the onAvailable is done when watchTargets resolves"); + const targets = []; + const onAvailable = async ({ targetFront }) => { + // Wait for one second here to check that watch targets waits for + // the onAvailable callbacks correctly. + await wait(1000); + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => + targets.splice(targets.indexOf(targetFront), 1); + + await targetList.watchTargets( + [TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed + ); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + is(targets.length, 1, "onAvailable has resolved"); + is( + targets[0], + serviceWorkerTargets[0], + "onAvailable was called with the expected service worker target" + ); + + info("Unregister the worker and wait until onDestroyed is called."); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + await waitUntil(() => targets.length === 0); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js b/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js new file mode 100644 index 0000000000..dc9afdfe4c --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js @@ -0,0 +1,393 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API for service workers when navigating in content tabs. +// When the top level target navigates, we manually call onTargetAvailable for +// service workers which now match the page domain. We assert that the callbacks +// will be called the expected number of times here. + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const COM_PAGE_URL = URL_ROOT_SSL + "test_sw_page.html"; +const COM_WORKER_URL = URL_ROOT_SSL + "test_sw_page_worker.js"; +const ORG_PAGE_URL = URL_ROOT_ORG_SSL + "test_sw_page.html"; +const ORG_WORKER_URL = URL_ROOT_ORG_SSL + "test_sw_page_worker.js"; + +/** + * This test will navigate between two pages, both controlled by different + * service workers. + * + * The steps will be: + * - navigate to .com page + * - create target list + * - navigate to .org page + * - reload .org page + * - unregister .org worker + * - navigate back to .com page + * - unregister .com worker + * + * First we test this with destroyServiceWorkersOnNavigation = false. + * In this case we expect the following calls: + * - navigate to .com page + * - create target list + * - onAvailable should be called for the .com worker + * - navigate to .org page + * - onAvailable should be called for the .org worker + * - reload .org page + * - nothing should happen + * - unregister .org worker + * - onDestroyed should be called for the .org worker + * - navigate back to .com page + * - nothing should happen + * - unregister .com worker + * - onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_NoDestroy() { + const { client, mainRoot } = await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, targetList } = await watchServiceWorkerTargets({ + mainRoot, + tab, + destroyServiceWorkersOnNavigation: false, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should not be called"); + const reloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(gBrowser.selectedTab); + await reloaded; + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Go back to page 1"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 2, destroyed: 2, targets: [] }); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + await client.close(); + await removeTab(tab); +}); + +/** + * Same scenario as test_NavigationBetweenTwoDomains_NoDestroy, but this time + * with destroyServiceWorkersOnNavigation set to true. + * + * In this case we expect the following calls: + * - navigate to .com page + * - create target list + * - onAvailable should be called for the .com worker + * - navigate to .org page + * - onDestroyed should be called for the .com worker + * - onAvailable should be called for the .org worker + * - reload .org page + * - onDestroyed & onAvailable should be called for the .org worker + * - unregister .org worker + * - onDestroyed should be called for the .org worker + * - navigate back to .com page + * - onAvailable should be called for the .com worker + * - unregister .com worker + * - onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_WithDestroy() { + const { client, mainRoot } = await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, targetList } = await watchServiceWorkerTargets({ + mainRoot, + tab, + destroyServiceWorkersOnNavigation: true, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should be called"); + gBrowser.reloadTab(gBrowser.selectedTab); + await checkHooks(hooks, { + available: 3, + destroyed: 2, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { available: 3, destroyed: 3, targets: [] }); + + info("Go back to page 1, wait for onDestroyed and onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 4, + destroyed: 3, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 4, destroyed: 4, targets: [] }); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + await client.close(); + await removeTab(tab); +}); + +/** + * In this test we load a service worker in a page prior to starting the + * TargetList. We start the target list on another page, and then we go back to + * the first page. We want to check that we are correctly notified about the + * worker that was spawned before TargetList. + * + * Steps: + * - navigate to .com page + * - navigate to .org page + * - create target list + * - unregister .org worker + * - navigate back to .com page + * - unregister .com worker + * + * The expected calls are the same whether destroyServiceWorkersOnNavigation is + * true or false. + * + * Expected calls: + * - navigate to .com page + * - navigate to .org page + * - create target list + * - onAvailable is called for the .org worker + * - unregister .org worker + * - onDestroyed is called for the .org worker + * - navigate back to .com page + * - onAvailable is called for the .com worker + * - unregister .com worker + * - onDestroyed is called for the .com worker + */ +add_task(async function test_NavigationToPageWithExistingWorker_NoDestroy() { + await testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation: false, + }); +}); + +add_task(async function test_NavigationToPageWithExistingWorker_WithDestroy() { + await testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation: true, + }); +}); + +async function testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation, +}) { + const { client, mainRoot } = await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL); + + info("Navigate to another page"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, ORG_PAGE_URL); + + // Avoid TV failures, where target list still starts thinking that the + // current domain is .com . + info("Wait until we have fully navigated to the .org page"); + await waitForRegistrationReady(tab, ORG_PAGE_URL); + + const { hooks, targetList } = await watchServiceWorkerTargets({ + mainRoot, + tab, + destroyServiceWorkersOnNavigation, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { available: 1, destroyed: 1, targets: [] }); + + info("Go back .com page, wait for onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 2, destroyed: 2, targets: [] }); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + await client.close(); + await removeTab(tab); +} + +async function setupServiceWorkerNavigationTest() { + // Enabled devtools.browsertoolbox.fission to listen to all target types. + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + return { client, mainRoot }; +} + +async function watchServiceWorkerTargets({ + destroyServiceWorkersOnNavigation, + mainRoot, + tab, +}) { + info("Create a target list for a tab target"); + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + // Enable Service Worker listening. + targetList.listenForServiceWorkers = true; + info( + "Set targetList.destroyServiceWorkersOnNavigation to " + + destroyServiceWorkersOnNavigation + ); + targetList.destroyServiceWorkersOnNavigation = destroyServiceWorkersOnNavigation; + await targetList.startListening(); + + // Setup onAvailable & onDestroyed callbacks so that we can check how many + // times they are called and with which targetFront. + const hooks = { + availableCount: 0, + destroyedCount: 0, + targets: [], + }; + + const onAvailable = async ({ targetFront }) => { + hooks.availableCount++; + hooks.targets.push(targetFront); + }; + + const onDestroyed = ({ targetFront }) => { + hooks.destroyedCount++; + hooks.targets.splice(hooks.targets.indexOf(targetFront), 1); + }; + + await targetList.watchTargets( + [TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed + ); + + return { hooks, targetList }; +} + +async function unregisterServiceWorker(tab, expectedPageUrl) { + await waitForRegistrationReady(tab, expectedPageUrl); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +/** + * Wait until the expected URL is loaded and win.registration has resolved. + */ +async function waitForRegistrationReady(tab, expectedPageUrl) { + await asyncWaitUntil(() => + SpecialPowers.spawn(tab.linkedBrowser, [expectedPageUrl], function(_url) { + try { + const win = content.wrappedJSObject; + const isExpectedUrl = win.location.href === _url; + const hasRegistration = !!win.registrationPromise; + return isExpectedUrl && hasRegistration; + } catch (e) { + return false; + } + }) + ); +} + +/** + * Assert helper for the `hooks` object, updated by the onAvailable and + * onDestroyed callbacks. Assert that the callbacks have been called the + * expected number of times, with the expected targets. + */ +async function checkHooks(hooks, { available, destroyed, targets }) { + info(`Wait for availableCount=${available} and destroyedCount=${destroyed}`); + await waitUntil( + () => hooks.availableCount == available && hooks.destroyedCount == destroyed + ); + is(hooks.availableCount, available, "onAvailable was called as expected"); + is(hooks.destroyedCount, destroyed, "onDestroyed was called as expected"); + + is(hooks.targets.length, targets.length, "Expected number of targets"); + targets.forEach((url, i) => { + is(hooks.targets[i].url, url, `SW target ${i} has the expected url`); + }); +} diff --git a/devtools/shared/resources/tests/browser_target_list_switchToTarget.js b/devtools/shared/resources/tests/browser_target_list_switchToTarget.js new file mode 100644 index 0000000000..ea166e40fc --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_switchToTarget.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API switchToTarget function + +const { TargetList } = require("devtools/shared/resources/target-list"); + +add_task(async function() { + const client = await createLocalClient(); + + await testSwitchToTarget(client); + + await client.close(); +}); + +async function testSwitchToTarget(client) { + info("Test TargetList.switchToTarget method"); + + const { mainRoot } = client; + // Create a first target to switch from, a new tab with an iframe + const firstTab = await addTab( + `data:text/html,<iframe src="data:text/html,foo"></iframe>` + ); + const firstDescriptor = await mainRoot.getTab({ tab: gBrowser.selectedTab }); + const firstTarget = await firstDescriptor.getTarget(); + + const targetList = new TargetList(mainRoot, firstTarget); + + await targetList.startListening(); + + is( + targetList.targetFront, + firstTarget, + "The target list top level target is the main process one" + ); + + // Create a second target to switch to, a new tab with an iframe + const secondTab = await addTab( + `data:text/html,<iframe src="data:text/html,bar"></iframe>` + ); + const secondDescriptor = await mainRoot.getTab({ tab: gBrowser.selectedTab }); + const secondTarget = await secondDescriptor.getTarget(); + + const frameTargets = []; + let currentTarget = firstTarget; + const onFrameAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == currentTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + // When calling watchTargets, this will be false, but it will be true when calling switchToTarget + is( + isTargetSwitching, + currentTarget == secondTarget, + "target switching boolean is correct" + ); + } else { + ok(!isTargetSwitching, "for now, only top level target can be switched"); + } + frameTargets.push(targetFront); + }; + const destroyedTargets = []; + const onFrameDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "target-destroyed: We are only notified about frame targets" + ); + ok( + targetFront == firstTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "target-destroyed: isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + is( + isTargetSwitching, + true, + "target-destroyed: target switching boolean is correct" + ); + } else { + ok( + !isTargetSwitching, + "target-destroyed: for now, only top level target can be switched" + ); + } + destroyedTargets.push(targetFront); + }; + await targetList.watchTargets( + [TargetList.TYPES.FRAME], + onFrameAvailable, + onFrameDestroyed + ); + + // Save the original list of targets + const createdTargets = [...frameTargets]; + // Clear the recorded target list of all existing targets + frameTargets.length = 0; + + currentTarget = secondTarget; + await targetList.switchToTarget(secondTarget); + + is( + targetList.targetFront, + currentTarget, + "After the switch, the top level target has been updated" + ); + // Because JS Window Actor API isn't used yet, FrameDescriptor.getTarget returns null + // And there is no target being created for the iframe, yet. + // As soon as bug 1565200 is resolved, this should return two frames, including the iframe. + is( + frameTargets.length, + 1, + "We get the report of the top level iframe when switching to the new target" + ); + is(frameTargets[0], currentTarget); + //is(frameTargets[1].url, "data:text/html,foo"); + + // Ensure that all the targets reported before the call to switchToTarget + // are reported as destroyed while calling switchToTarget. + is( + destroyedTargets.length, + createdTargets.length, + "All targets original reported are destroyed" + ); + for (const newTarget of createdTargets) { + ok( + destroyedTargets.includes(newTarget), + "Each originally target is reported as destroyed" + ); + } + + targetList.destroy(); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +} diff --git a/devtools/shared/resources/tests/browser_target_list_tab_workers.js b/devtools/shared/resources/tests/browser_target_list_tab_workers.js new file mode 100644 index 0000000000..e4e1a5a0dd --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_tab_workers.js @@ -0,0 +1,330 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around workers + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_FILE = "fission_iframe.html"; +const REMOTE_IFRAME_URL = URL_ROOT_ORG_SSL + IFRAME_FILE; +const IFRAME_URL = URL_ROOT_SSL + IFRAME_FILE; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const IFRAME_WORKER_URL = WORKER_FILE; + +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve + // workers loops through _all_ the workers in the process, which means it goes over workers + // from other tabs as well. Here we add a few tabs that are not going to be used in the + // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets. + await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`); + await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`); + + info("Test TargetList against workers via a tab target"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetList for the tab + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + + // Ensure attaching the target as BrowsingContextTargetActor.listWorkers + // assert that the target actor is attached. + // It isn't clear if this assertion is meaningful? + await target.attach(); + + const targetList = new TargetList(mainRoot, target); + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetList.listenForWorkers = true; + + await targetList.startListening(); + + info("Check that getAllTargets only returns dedicated workers"); + const workers = await targetList.getAllTargets([ + TYPES.WORKER, + TYPES.SHARED_WORKER, + ]); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + is(workers.length, 2, "Retrieved two workerโฆ"); + const mainPageWorker = workers.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorker = workers.find( + worker => worker.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + ok(mainPageWorker, "โฆthe dedicated worker on the main page"); + ok(iframeWorker, "โฆand the dedicated worker on the iframe"); + + info( + "Assert that watchTargets will call the create callback for existing dedicated workers" + ); + const targets = []; + const destroyedTargets = []; + const onAvailable = async ({ targetFront }) => { + info(`onAvailable called for ${targetFront.url}`); + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + targets.push(targetFront); + info(`Handled ${targets.length} targets\n`); + }; + const onDestroy = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + destroyedTargets.push(targetFront); + }; + + await targetList.watchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroy + ); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + info("Check that watched targets return the same fronts as getAllTargets"); + is(targets.length, 2, "watcheTargets retrieved 2 workerโฆ"); + const mainPageWorkerTarget = targets.find(t => t === mainPageWorker); + const iframeWorkerTarget = targets.find(t => t === iframeWorker); + + ok( + mainPageWorkerTarget, + "โฆthe dedicated worker in main page, which is the same front we received from getAllTargets" + ); + ok( + iframeWorkerTarget, + "โฆthe dedicated worker in iframe, which is the same front we received from getAllTargets" + ); + + info("Spawn workers in main page and iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER_FILE], workerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], innerWorkerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + }); + }); + + await TestUtils.waitForCondition( + () => targets.length === 4, + "Wait for the target list to notify us about the spawned worker" + ); + const mainPageSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url === `${WORKER_FILE}#spawned-worker` + ); + ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker"); + const iframeSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url === `${WORKER_FILE}#spawned-worker-in-iframe` + ); + ok(iframeSpawnedWorkerTarget, "Retrieved spawned worker in iframe"); + + await wait(100); + + info( + "Check that the target list calls onDestroy when a worker is terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + + SpecialPowers.spawn(content.document.querySelector("iframe"), [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + }); + }); + await TestUtils.waitForCondition( + () => + destroyedTargets.includes(mainPageSpawnedWorkerTarget) && + destroyedTargets.includes(iframeSpawnedWorkerTarget), + "Wait for the target list to notify us about the terminated workers" + ); + + ok( + true, + "The target list handled the terminated workers (from the main page and the iframe)" + ); + + info( + "Check that reloading the page will notify about the terminated worker and the new existing one" + ); + const targetsCountBeforeReload = targets.length; + tab.linkedBrowser.reload(); + + await TestUtils.waitForCondition(() => { + return ( + destroyedTargets.includes(mainPageWorkerTarget) && + destroyedTargets.includes(iframeWorkerTarget) + ); + }, `Wait for the target list to notify us about the terminated workers when reloading`); + ok( + true, + "The target list notified us about all the expected workers being destroyed when reloading" + ); + + await TestUtils.waitForCondition( + () => targets.length === targetsCountBeforeReload + 2, + "Wait for the target list to notify us about the new workers after reloading" + ); + + const mainPageWorkerTargetAfterReload = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterReload = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterReload, + "The target list handled the worker created once the page navigated" + ); + ok( + iframeWorkerTargetAfterReload, + "The target list handled the worker created in the iframe once the page navigated" + ); + + const targetCount = targets.length; + + info( + "Check that when removing an iframe we're notified about its workers being terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.querySelector("iframe").remove(); + }); + await TestUtils.waitForCondition(() => { + return destroyedTargets.includes(iframeWorkerTargetAfterReload); + }, `Wait for the target list to notify us about the terminated workers when removing an iframe`); + + info("Check that target list handles adding iframes with workers"); + const iframeUrl = `${IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-iframe`; + const remoteIframeUrl = `${REMOTE_IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-remote-iframe`; + + await SpecialPowers.spawn( + tab.linkedBrowser, + [iframeUrl, remoteIframeUrl], + (url, remoteUrl) => { + const firstIframe = content.document.createElement("iframe"); + content.document.body.append(firstIframe); + firstIframe.src = url + "-1"; + + const secondIframe = content.document.createElement("iframe"); + content.document.body.append(secondIframe); + secondIframe.src = url + "-2"; + + const firstRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(firstRemoteIframe); + firstRemoteIframe.src = remoteUrl + "-1"; + + const secondRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(secondRemoteIframe); + secondRemoteIframe.src = remoteUrl + "-2"; + } + ); + + // It's important to check the length of `targets` here to ensure we don't get unwanted + // worker targets. + await TestUtils.waitForCondition( + () => targets.length === targetCount + 4, + "Wait for the target list to notify us about the workers in the new iframes" + ); + const firstSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_FILE}#simple-worker-in-created-iframe-1` + ); + const secondSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_FILE}#simple-worker-in-created-iframe-2` + ); + const firstSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == `${WORKER_FILE}#simple-worker-in-created-remote-iframe-1` + ); + const secondSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == `${WORKER_FILE}#simple-worker-in-created-remote-iframe-2` + ); + + ok( + firstSpawnedIframeWorkerTarget, + "The target list handled the worker in the first new same-origin iframe" + ); + ok( + secondSpawnedIframeWorkerTarget, + "The target list handled the worker in the second new same-origin iframe" + ); + ok( + firstSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the first new remote iframe" + ); + ok( + secondSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the second new remote iframe" + ); + + info("Check that navigating away does destroy all targets"); + BrowserTestUtils.loadURI( + tab.linkedBrowser, + "data:text/html,<meta charset=utf8>Away" + ); + + await TestUtils.waitForCondition( + () => destroyedTargets.length === targets.length, + "Wait for all the targets to be reporeted as destroyed" + ); + + ok( + destroyedTargets.includes(mainPageWorkerTargetAfterReload), + "main page worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedIframeWorkerTarget), + "first spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedIframeWorkerTarget), + "second spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedRemoteIframeWorkerTarget), + "first spawned remote iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedRemoteIframeWorkerTarget), + "second spawned remote iframe worker target was destroyed" + ); + + targetList.unwatchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroy + ); + targetList.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(client); + + BrowserTestUtils.removeTab(tab); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_target_list_watchTargets.js b/devtools/shared/resources/tests/browser_target_list_watchTargets.js new file mode 100644 index 0000000000..68d0a381fd --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_watchTargets.js @@ -0,0 +1,256 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList's `watchTargets` function + +const { TargetList } = require("devtools/shared/resources/target-list"); + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function() { + // Enabled fission's pref as the TargetList is almost disabled without it + await pushPref("devtools.browsertoolbox.fission", true); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + await testWatchTargets(mainRoot); + await testContentProcessTarget(mainRoot); + await testThrowingInOnAvailable(mainRoot); + + await client.close(); +}); + +async function testWatchTargets(mainRoot) { + info("Test TargetList watchTargets function"); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroyed without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are not notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + is( + targets.size, + originalProcessesCount, + "retrieved the expected number of processes via watchTargets" + ); + // Start from 1 in order to ignore the parent process target, which is considered as a frame rather than a process + for (let i = 1; i < Services.ppmm.childCount; i++) { + const process = Services.ppmm.getChildAt(i); + const hasTargetWithSamePID = [...targets].find( + processTarget => processTarget.targetForm.processID == process.osPid + ); + ok( + hasTargetWithSamePID, + `Process with PID ${process.osPid} has been reported via onAvailable` + ); + } + + info( + "Check that onAvailable is called for processes created *after* the call to watchTargets" + ); + const previousTargets = new Set(targets); + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetList.unwatchTargets([TargetList.TYPES.PROCESS], onAvailable2); + resolve(targetFront); + }; + targetList.watchTargets([TargetList.TYPES.PROCESS], onAvailable2); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the side of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroyed is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }; + targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + + targetList.destroy(); +} + +async function testContentProcessTarget(mainRoot) { + info("Test TargetList watchTargets with a content process target"); + + const processes = await mainRoot.listProcesses(); + const target = await processes[1].getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Assert that watchTargets is only called for the top level content process target + // as listening for additional target is only enable for the parent process target. + // See bug 1593928. + const targets = new Set(); + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + // This may fail if the top level target is reported by LegacyImplementation + // to TargetList and emits an available event for it. + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + is(targetFront, target, "This is the existing top level target"); + ok( + targetFront.isTopLevel, + "We are only notified about the top level target" + ); + targets.add(targetFront); + }; + const onDestroyed = _ => { + ok(false, "onDestroyed should never be called in this test"); + }; + await targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + + // This may fail if the top level target is reported by LegacyImplementation + // to TargetList and registers a duplicated entry + is(targets.size, 1, "We were only notified about the top level target"); + + targetList.destroy(); +} + +async function testThrowingInOnAvailable(mainRoot) { + info( + "Test TargetList watchTargets function when an exception is thrown in onAvailable callback" + ); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + let thrown = false; + const onAvailable = ({ targetFront }) => { + if (!thrown) { + thrown = true; + throw new Error("Force an exception when processing the first target"); + } + targets.add(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.PROCESS], onAvailable); + is( + targets.size, + originalProcessesCount - 1, + "retrieved the expected number of processes via onAvailable. All but the first one where we have thrown." + ); + + targetList.destroy(); +} diff --git a/devtools/shared/resources/tests/early_console_document.html b/devtools/shared/resources/tests/early_console_document.html new file mode 100644 index 0000000000..e4523dbdeb --- /dev/null +++ b/devtools/shared/resources/tests/early_console_document.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + console.log("early-page-log"); + </script> +</head> +<body></body> +</html> diff --git a/devtools/shared/resources/tests/fission_document.html b/devtools/shared/resources/tests/fission_document.html new file mode 100644 index 0000000000..24f9704a76 --- /dev/null +++ b/devtools/shared/resources/tests/fission_document.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + + const params = new URLSearchParams(document.location.search); + + // eslint-disable-next-line no-unused-vars + const worker = new Worker("https://example.com/browser/devtools/shared/resources/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/resources/tests/test_worker.js#shared-worker"); + + if (!params.has("noServiceWorker")) { + // Expose a reference to the registration so that tests can unregister it. + window.registrationPromise = navigator.serviceWorker.register("https://example.com/browser/devtools/shared/resources/tests/test_service_worker.js#service-worker"); + } + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/resources/tests/fission_iframe.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/resources/tests/fission_iframe.html b/devtools/shared/resources/tests/fission_iframe.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/resources/tests/fission_iframe.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + const params = new URLSearchParams(document.location.search); + const hashSuffix = params.get("hashSuffix") || "in-iframe"; + // eslint-disable-next-line no-unused-vars + const worker = new Worker("test_worker.js#simple-worker-" + hashSuffix); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("test_worker.js#shared-worker-" + hashSuffix); + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/resources/tests/head.js b/devtools/shared/resources/tests/head.js new file mode 100644 index 0000000000..988592f9a1 --- /dev/null +++ b/devtools/shared/resources/tests/head.js @@ -0,0 +1,150 @@ +/* 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/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ +/* import-globals-from ../../../client/shared/test/shared-head.js */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { DevToolsClient } = require("devtools/client/devtools-client"); +const { DevToolsServer } = require("devtools/server/devtools-server"); + +async function createLocalClient() { + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + await client.connect(); + return client; +} + +async function _initResourceWatcherFromDescriptor( + client, + descriptor, + { listenForWorkers = false } = {} +) { + const { TargetList } = require("devtools/shared/resources/target-list"); + const { + ResourceWatcher, + } = require("devtools/shared/resources/resource-watcher"); + + const target = await descriptor.getTarget(); + const targetList = new TargetList(client.mainRoot, target); + if (listenForWorkers) { + targetList.listenForWorkers = true; + } + await targetList.startListening(); + + // Now create a ResourceWatcher + const resourceWatcher = new ResourceWatcher(targetList); + + return { client, resourceWatcher, targetList }; +} + +/** + * Instantiate a ResourceWatcher for the given tab. + * + * @param {Tab} tab + * The browser frontend's tab to connect to. + * @param {Object} options + * @param {Boolean} options.listenForWorkers + * @return {Object} object + * @return {ResourceWatcher} object.client + * The underlying client instance. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetList + * The underlying target list instance. + */ +async function initResourceWatcher(tab, options) { + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getTab({ tab }); + return _initResourceWatcherFromDescriptor(client, descriptor, options); +} + +/** + * Instantiate a multi-process ResourceWatcher, watching all type of targets. + * + * @return {Object} object + * @return {ResourceWatcher} object.client + * The underlying client instance. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetList + * The underlying target list instance. + */ +async function initMultiProcessResourceWatcher() { + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getMainProcess(); + return _initResourceWatcherFromDescriptor(client, descriptor); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkObject(object, expected) { + if (object && object.getGrip) { + object = object.getGrip(); + } + + for (const name of Object.keys(expected)) { + const expectedValue = expected[name]; + const value = object[name]; + checkValue(name, value, expectedValue); + } +} + +function checkValue(name, value, expected) { + if (expected === null) { + is(value, null, `'${name}' is null`); + } else if (value === undefined) { + is(value, undefined, `'${name}' is undefined`); + } else if (value === null) { + is(value, expected, `'${name}' has expected value`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'"); + } else if (expected instanceof RegExp) { + ok(expected.test(value), name + ": " + expected + " matched " + value); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'"); + ok(Array.isArray(value), `property '${name}' is an array`); + + is(value.length, expected.length, "Array has expected length"); + if (value.length !== expected.length) { + is(JSON.stringify(value, null, 2), JSON.stringify(expected, null, 2)); + } else { + checkObject(value, expected); + } + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'"); + checkObject(value, expected); + } +} + +async function triggerNetworkRequests(browser, commands) { + for (let i = 0; i < commands.length; i++) { + await SpecialPowers.spawn(browser, [commands[i]], async function(code) { + const script = content.document.createElement("script"); + script.append( + content.document.createTextNode( + `async function triggerRequest() {${code}}` + ) + ); + content.document.body.append(script); + await content.wrappedJSObject.triggerRequest(); + script.remove(); + }); + } +} diff --git a/devtools/shared/resources/tests/network_document.html b/devtools/shared/resources/tests/network_document.html new file mode 100644 index 0000000000..5c4744cb0c --- /dev/null +++ b/devtools/shared/resources/tests/network_document.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>Test for network events</title> + </head> + <body> + <p>Test for network events</p> + </body> +</html> diff --git a/devtools/shared/resources/tests/service-worker-sources.js b/devtools/shared/resources/tests/service-worker-sources.js new file mode 100644 index 0000000000..614644ee5d --- /dev/null +++ b/devtools/shared/resources/tests/service-worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function serviceWorkerSource() {} diff --git a/devtools/shared/resources/tests/sources.html b/devtools/shared/resources/tests/sources.html new file mode 100644 index 0000000000..d765f25ef0 --- /dev/null +++ b/devtools/shared/resources/tests/sources.html @@ -0,0 +1,22 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <script type="text/javascript"> + "use strict"; + /* eslint-disable */ + function inlineSource() {} + // Assign it to a global in order to avoid it being GCed + eval("this.global = function evalFunction() {}"); + // Assign the worker to a global variable in order to avoid + // having it be GCed. + this.worker = new Worker("worker-sources.js"); + window.registrationPromise = navigator.serviceWorker.register("service-worker-sources.js"); + </script> + <script src="sources.js"></script> + </body> +</html> diff --git a/devtools/shared/resources/tests/sources.js b/devtools/shared/resources/tests/sources.js new file mode 100644 index 0000000000..7ae6c6272b --- /dev/null +++ b/devtools/shared/resources/tests/sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function scriptSource() {} diff --git a/devtools/shared/resources/tests/style_document.css b/devtools/shared/resources/tests/style_document.css new file mode 100644 index 0000000000..aa54533924 --- /dev/null +++ b/devtools/shared/resources/tests/style_document.css @@ -0,0 +1 @@ +body { margin: 1px; } diff --git a/devtools/shared/resources/tests/style_document.html b/devtools/shared/resources/tests/style_document.html new file mode 100644 index 0000000000..1b5e97dfe7 --- /dev/null +++ b/devtools/shared/resources/tests/style_document.html @@ -0,0 +1,16 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { color: lime; } + </style> + <link href="style_document.css" rel="stylesheet" type="text/css"/> + </head> + <body> + <iframe src="https://example.org/browser/devtools/shared/resources/tests/style_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/resources/tests/style_iframe.css b/devtools/shared/resources/tests/style_iframe.css new file mode 100644 index 0000000000..30e7ae802b --- /dev/null +++ b/devtools/shared/resources/tests/style_iframe.css @@ -0,0 +1 @@ +body { padding: 1px; } diff --git a/devtools/shared/resources/tests/style_iframe.html b/devtools/shared/resources/tests/style_iframe.html new file mode 100644 index 0000000000..11ad9f785b --- /dev/null +++ b/devtools/shared/resources/tests/style_iframe.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { background-color: pink; } + </style> + <link href="style_iframe.css" rel="stylesheet" type="text/css"/> + </head> + <body> + </body> +</html> diff --git a/devtools/shared/resources/tests/test_service_worker.js b/devtools/shared/resources/tests/test_service_worker.js new file mode 100644 index 0000000000..3b69a40dcd --- /dev/null +++ b/devtools/shared/resources/tests/test_service_worker.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We don't need any computation in the worker, +// but at least register a fetch listener so that +// we force instantiating the SW when loading the page. +self.onfetch = function(event) { + // do nothing. +}; diff --git a/devtools/shared/resources/tests/test_sw_page.html b/devtools/shared/resources/tests/test_sw_page.html new file mode 100644 index 0000000000..38aad04259 --- /dev/null +++ b/devtools/shared/resources/tests/test_sw_page.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test sw page</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test sw page</p> + +<script> +"use strict"; + +// Expose a reference to the registration so that tests can unregister it. +window.registrationPromise = navigator.serviceWorker.register("test_sw_page_worker.js"); +</script> +</body> +</html> diff --git a/devtools/shared/resources/tests/test_sw_page_worker.js b/devtools/shared/resources/tests/test_sw_page_worker.js new file mode 100644 index 0000000000..29cda68560 --- /dev/null +++ b/devtools/shared/resources/tests/test_sw_page_worker.js @@ -0,0 +1,5 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// We don't need any computation in the worker, +// just it to be alive diff --git a/devtools/shared/resources/tests/test_worker.js b/devtools/shared/resources/tests/test_worker.js new file mode 100644 index 0000000000..873041fcf0 --- /dev/null +++ b/devtools/shared/resources/tests/test_worker.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +console.log("[WORKER] started", globalThis.location.toString(), globalThis); + +globalThis.onmessage = function(e) { + const { type, message } = e.data; + + if (type === "log-in-worker") { + // Printing `e` so we can check that we have an object and not a stringified version + console.log("[WORKER]", message, e); + } +}; diff --git a/devtools/shared/resources/tests/websocket_backend_wsh.py b/devtools/shared/resources/tests/websocket_backend_wsh.py new file mode 100644 index 0000000000..564715114b --- /dev/null +++ b/devtools/shared/resources/tests/websocket_backend_wsh.py @@ -0,0 +1,21 @@ +from __future__ import absolute_import +from mod_pywebsocket import msgutil + + +def web_socket_do_extra_handshake(request): + pass + + +def web_socket_transfer_data(request): + while not request.client_terminated: + resp = msgutil.receive_message(request) + msgutil.send_message(request, resp) + + +def web_socket_passive_closing_handshake(request): + # If we use `pass` here, the `payload` of `frameReceived` which will be happened + # of communication of closing will be `\u0003รจ`. In order to make the `payload` + # to be empty string, return code and reason explicitly. + code = None + reason = None + return code, reason diff --git a/devtools/shared/resources/tests/websocket_frontend.html b/devtools/shared/resources/tests/websocket_frontend.html new file mode 100644 index 0000000000..ce9b3b93bd --- /dev/null +++ b/devtools/shared/resources/tests/websocket_frontend.html @@ -0,0 +1,39 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "ws://mochi.test:8888/browser/devtools/shared/resources/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + </body> +</html> diff --git a/devtools/shared/resources/tests/worker-sources.js b/devtools/shared/resources/tests/worker-sources.js new file mode 100644 index 0000000000..dcf2ed8031 --- /dev/null +++ b/devtools/shared/resources/tests/worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function workerSource() {} diff --git a/devtools/shared/resources/transformers/console-messages.js b/devtools/shared/resources/transformers/console-messages.js new file mode 100644 index 0000000000..536c3e2b6e --- /dev/null +++ b/devtools/shared/resources/transformers/console-messages.js @@ -0,0 +1,23 @@ +/* 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/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "devtools/client/fronts/object", + true +); + +module.exports = function({ resource, targetFront }) { + if (Array.isArray(resource.message.arguments)) { + // We might need to create fronts for each of the message arguments. + resource.message.arguments = resource.message.arguments.map(arg => + getAdHocFrontOrPrimitiveGrip(arg, targetFront) + ); + } + return resource; +}; diff --git a/devtools/shared/resources/transformers/error-messages.js b/devtools/shared/resources/transformers/error-messages.js new file mode 100644 index 0000000000..8d6e2b821e --- /dev/null +++ b/devtools/shared/resources/transformers/error-messages.js @@ -0,0 +1,31 @@ +/* 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/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "devtools/client/fronts/object", + true +); + +module.exports = function({ resource, targetFront }) { + if (resource?.pageError?.errorMessage) { + resource.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip( + resource.pageError.errorMessage, + targetFront + ); + } + + if (resource?.pageError?.exception) { + resource.pageError.exception = getAdHocFrontOrPrimitiveGrip( + resource.pageError.exception, + targetFront + ); + } + + return resource; +}; diff --git a/devtools/shared/resources/transformers/moz.build b/devtools/shared/resources/transformers/moz.build new file mode 100644 index 0000000000..0a124db7a8 --- /dev/null +++ b/devtools/shared/resources/transformers/moz.build @@ -0,0 +1,11 @@ +# 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/. + +DevToolsModules( + "console-messages.js", + "error-messages.js", + "network-events.js", + "storage-local-storage.js", + "storage-session-storage.js", +) diff --git a/devtools/shared/resources/transformers/network-events.js b/devtools/shared/resources/transformers/network-events.js new file mode 100644 index 0000000000..55a0efea77 --- /dev/null +++ b/devtools/shared/resources/transformers/network-events.js @@ -0,0 +1,16 @@ +/* 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/. */ + +"use strict"; + +const { + getUrlDetails, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("devtools/client/netmonitor/src/utils/request-utils"); + +module.exports = function({ resource }) { + resource.urlDetails = getUrlDetails(resource.url); + resource.startedMs = Date.parse(resource.startedDateTime); + return resource; +}; diff --git a/devtools/shared/resources/transformers/storage-local-storage.js b/devtools/shared/resources/transformers/storage-local-storage.js new file mode 100644 index 0000000000..d9d78e1f10 --- /dev/null +++ b/devtools/shared/resources/transformers/storage-local-storage.js @@ -0,0 +1,23 @@ +/* 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/. */ + +"use strict"; + +const { + TYPES: { LOCAL_STORAGE }, +} = require("devtools/shared/resources/resource-watcher"); + +const { Front, types } = require("devtools/shared/protocol.js"); + +module.exports = function({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("localStorage").read(resource, targetFront); + resource.resourceType = LOCAL_STORAGE; + resource.resourceId = LOCAL_STORAGE; + resource.resourceKey = "localStorage"; + } + + return resource; +}; diff --git a/devtools/shared/resources/transformers/storage-session-storage.js b/devtools/shared/resources/transformers/storage-session-storage.js new file mode 100644 index 0000000000..2a346fbfaf --- /dev/null +++ b/devtools/shared/resources/transformers/storage-session-storage.js @@ -0,0 +1,23 @@ +/* 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/. */ + +"use strict"; + +const { + TYPES: { SESSION_STORAGE }, +} = require("devtools/shared/resources/resource-watcher"); + +const { Front, types } = require("devtools/shared/protocol.js"); + +module.exports = function({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for session storage + resource = types.getType("sessionStorage").read(resource, targetFront); + resource.resourceType = SESSION_STORAGE; + resource.resourceId = SESSION_STORAGE; + resource.resourceKey = "sessionStorage"; + } + + return resource; +}; |