diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
commit | 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch) | |
tree | a31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /devtools/shared/commands/target | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream/115.8.0esr.tar.xz firefox-esr-upstream/115.8.0esr.zip |
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/commands/target')
42 files changed, 6341 insertions, 0 deletions
diff --git a/devtools/shared/commands/target/actions/moz.build b/devtools/shared/commands/target/actions/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/actions/moz.build @@ -0,0 +1,7 @@ +# 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( + "targets.js", +) diff --git a/devtools/shared/commands/target/actions/targets.js b/devtools/shared/commands/target/actions/targets.js new file mode 100644 index 0000000000..577e5fedd3 --- /dev/null +++ b/devtools/shared/commands/target/actions/targets.js @@ -0,0 +1,33 @@ +/* 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"; + +function registerTarget(targetFront) { + return { type: "REGISTER_TARGET", targetFront }; +} + +function unregisterTarget(targetFront) { + return { type: "UNREGISTER_TARGET", targetFront }; +} + +/** + * + * @param {String} targetActorID: The actorID of the target we want to select. + */ +function selectTarget(targetActorID) { + return function ({ dispatch, getState }) { + dispatch({ type: "SELECT_TARGET", targetActorID }); + }; +} + +function refreshTargets() { + return { type: "REFRESH_TARGETS" }; +} + +module.exports = { + registerTarget, + unregisterTarget, + selectTarget, + refreshTargets, +}; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js new file mode 100644 index 0000000000..e0c5b18d51 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js @@ -0,0 +1,72 @@ +/* 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(targetCommand, onTargetAvailable, onTargetDestroyed) { + this.targetCommand = targetCommand; + this.rootFront = targetCommand.rootFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.descriptors = new Set(); + this._processListChanged = this._processListChanged.bind(this); + } + + async _processListChanged() { + if (this.targetCommand.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/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js new file mode 100644 index 0000000000..adaeb9def4 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js @@ -0,0 +1,316 @@ +/* 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 { + WorkersListener, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/shared/workers-listener.js"); + +const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"); + +class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher { + // Holds the current target URL object + #currentTargetURL; + + constructor(targetCommand, onTargetAvailable, onTargetDestroyed, commands) { + super(targetCommand, onTargetAvailable, onTargetDestroyed); + this._registrations = []; + this._processTargets = new Set(); + this.commands = commands; + + // 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._onDocumentEvent = this._onDocumentEvent.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.targetCommand.targetFront; + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + this.#currentTargetURL = new URL(this.targetCommand.targetFront.url); + } + + 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.targetCommand.descriptorFront.isTabDescriptor) { + await this.commands.resourceCommand.watchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onDocumentEvent, + ignoreExistingResources: true, + } + ); + } + + await super.listen(); + } + + // Override from LegacyWorkersWatcher. + unlisten(...args) { + this._workersListener.removeListener(this._onRegistrationListChanged); + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + this.commands.resourceCommand.unwatchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onDocumentEvent, + } + ); + } + + super.unlisten(...args); + } + + // Override from LegacyWorkersWatcher. + async _onProcessAvailable({ targetFront }) { + if (this.targetCommand.descriptorFront.isTabDescriptor) { + // 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. + try { + // To support early breakpoint we need to setup the + // `pauseMatchingServiceWorkers` mechanism in each process. + await targetFront.pauseMatchingServiceWorkers({ + origin: this.#currentTargetURL.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.targetCommand.destroyServiceWorkersOnNavigation; + } + + _onProcessDestroyed({ targetFront }) { + this._processTargets.delete(targetFront); + return super._onProcessDestroyed({ targetFront }); + } + + _onDocumentEvent(resources) { + for (const resource of resources) { + if ( + resource.resourceType !== + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT + ) { + continue; + } + + if (resource.name === "will-navigate") { + // We rely on will-navigate as the onTargetAvailable for the top-level frame can + // happen after the onTargetAvailable for processes (handled in _onProcessAvailable), + // where we need the origin we navigate to. + this.#currentTargetURL = new URL(resource.newURI); + continue; + } + + // Note that we rely on "dom-loading" 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". + // On the other end, "dom-complete", which is a better mapping of "navigate", is + // happening too late (because of resources being throttled), and would cause failures + // in test (like browser_target_command_service_workers_navigation.js), as the new worker + // target would already be registered at this point, and seen as something that would + // need to be destroyed. + if (resource.name === "dom-loading") { + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + const shouldDestroy = this._shouldDestroyTargetsOnNavigation(); + + for (const target of allServiceWorkerTargets) { + const isRegisteredBefore = + this.targetCommand.isTargetRegistered(target); + if (shouldDestroy && isRegisteredBefore) { + // Instruct the target command to notify about the worker target destruction + // but do not destroy the front as we want to keep using it. + // We will notify about it again via onTargetAvailable. + this.onTargetDestroyed(target, { shouldDestroyTargetFront: false }); + } + + // Note: we call isTargetRegistered again because calls to + // onTargetDestroyed might have modified the list of registered targets. + const isRegisteredAfter = + this.targetCommand.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.targetCommand.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.targetCommand.isTargetRegistered(target)) { + // Only emit onTargetDestroyed if it wasn't already done by + // onNavigate (ie the target is still tracked by TargetCommand) + 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.targetCommand.descriptorFront.isBrowserProcessDescriptor) { + // All registrations are valid for main process debugging. + return true; + } + + if (!this.targetCommand.descriptorFront.isTabDescriptor) { + // No support for service worker targets outside of main process & + // 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 = this.#currentTargetURL.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/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js new file mode 100644 index 0000000000..b248e6aef7 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.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 LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"); + +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/commands/target/legacy-target-watchers/legacy-workers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js new file mode 100644 index 0000000000..0baa14757b --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js @@ -0,0 +1,238 @@ +/* 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 LegacyProcessesWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js"); + +class LegacyWorkersWatcher { + constructor(targetCommand, onTargetAvailable, onTargetDestroyed) { + this.targetCommand = targetCommand; + this.rootFront = targetCommand.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.targetCommand.isDestroyed()) { + return; + } + + let workers; + try { + ({ workers } = await front.listWorkers()); + } catch (e) { + // Workers may be added/removed at anytime so that listWorkers request + // can be spawn during a toolbox destroy sequence and easily fail + if (front.isDestroyed()) { + return; + } + throw e; + } + + // 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.targetCommand.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.targetCommand.targetFront; + + if (this.target.isParentProcess) { + await this.targetCommand.watchTargets({ + types: [this.targetCommand.TYPES.PROCESS], + onAvailable: this._onProcessAvailable, + onDestroyed: 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.targetCommand, + 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, + this.targetsByProcess.get(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.targetCommand.getAllTargets([this.targetCommand.TYPES.PROCESS]); + } + + unlisten({ isTargetSwitching } = {}) { + // Stop listening for new process targets. + if (this.target.isParentProcess) { + this.targetCommand.unwatchTargets({ + types: [this.targetCommand.TYPES.PROCESS], + onAvailable: this._onProcessAvailable, + onDestroyed: 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 TargetCommand 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); + + // When unlisten is called from a target switch and service workers targets are not + // destroyed on navigation, we don't want to remove the targets from targetsByProcess + if ( + !isTargetSwitching || + !this._isServiceWorkerWatcher || + this.targetCommand.destroyServiceWorkersOnNavigation + ) { + 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/commands/target/legacy-target-watchers/moz.build b/devtools/shared/commands/target/legacy-target-watchers/moz.build new file mode 100644 index 0000000000..60fdd7ec22 --- /dev/null +++ b/devtools/shared/commands/target/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/commands/target/moz.build b/devtools/shared/commands/target/moz.build new file mode 100644 index 0000000000..c23940d7ed --- /dev/null +++ b/devtools/shared/commands/target/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 += [ + "actions", + "legacy-target-watchers", + "reducers", + "selectors", +] + +DevToolsModules( + "target-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"] diff --git a/devtools/shared/commands/target/reducers/moz.build b/devtools/shared/commands/target/reducers/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/reducers/moz.build @@ -0,0 +1,7 @@ +# 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( + "targets.js", +) diff --git a/devtools/shared/commands/target/reducers/targets.js b/devtools/shared/commands/target/reducers/targets.js new file mode 100644 index 0000000000..2e93ddd7f0 --- /dev/null +++ b/devtools/shared/commands/target/reducers/targets.js @@ -0,0 +1,70 @@ +/* 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 initialReducerState = { + // Array of targetFront + targets: [], + // The selected targetFront instance + selected: null, + // timestamp of the last time a target was updated (i.e. url/title was updated). + // This is used by the EvaluationContextSelector component to re-render the list of + // targets when the list itself did not change (no addition/removal) + lastTargetRefresh: Date.now(), +}; + +function update(state = initialReducerState, action) { + switch (action.type) { + case "SELECT_TARGET": { + const { targetActorID } = action; + + if (state.selected?.actorID === targetActorID) { + return state; + } + + const selectedTarget = state.targets.find( + target => target.actorID === targetActorID + ); + + // It's possible that the target reducer is missing a target + // e.g. workers, remote iframes, etc. (Bug 1594754) + if (!selectedTarget) { + return state; + } + + return { ...state, selected: selectedTarget }; + } + + case "REGISTER_TARGET": { + return { + ...state, + targets: [...state.targets, action.targetFront], + }; + } + + case "REFRESH_TARGETS": { + // The data _in_ targetFront was updated, so we only need to mutate the state, + // while keeping the same values. + return { + ...state, + lastTargetRefresh: Date.now(), + }; + } + + case "UNREGISTER_TARGET": { + const targets = state.targets.filter( + target => target !== action.targetFront + ); + + let { selected } = state; + if (selected === action.targetFront) { + selected = null; + } + + return { ...state, targets, selected }; + } + } + return state; +} +module.exports = update; diff --git a/devtools/shared/commands/target/selectors/moz.build b/devtools/shared/commands/target/selectors/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/selectors/moz.build @@ -0,0 +1,7 @@ +# 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( + "targets.js", +) diff --git a/devtools/shared/commands/target/selectors/targets.js b/devtools/shared/commands/target/selectors/targets.js new file mode 100644 index 0000000000..95da81bbba --- /dev/null +++ b/devtools/shared/commands/target/selectors/targets.js @@ -0,0 +1,20 @@ +/* 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"; + +function getToolboxTargets(state) { + return state.targets; +} + +function getSelectedTarget(state) { + return state.selected; +} + +function getLastTargetRefresh(state) { + return state.lastTargetRefresh; +} + +exports.getToolboxTargets = getToolboxTargets; +exports.getSelectedTarget = getSelectedTarget; +exports.getLastTargetRefresh = getLastTargetRefresh; diff --git a/devtools/shared/commands/target/target-command.js b/devtools/shared/commands/target/target-command.js new file mode 100644 index 0000000000..28e70c9f4b --- /dev/null +++ b/devtools/shared/commands/target/target-command.js @@ -0,0 +1,1173 @@ +/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const BROWSERTOOLBOX_SCOPE_PREF = "devtools.browsertoolbox.scope"; +// Possible values of the previous pref: +const BROWSERTOOLBOX_SCOPE_EVERYTHING = "everything"; +const BROWSERTOOLBOX_SCOPE_PARENTPROCESS = "parent-process"; + +// eslint-disable-next-line mozilla/reject-some-requires +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducer = require("resource://devtools/shared/commands/target/reducers/targets.js"); + +loader.lazyRequireGetter( + this, + ["refreshTargets", "registerTarget", "unregisterTarget"], + "resource://devtools/shared/commands/target/actions/targets.js", + true +); + +class TargetCommand extends EventEmitter { + #selectedTargetFront; + /** + * 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 {DescriptorFront} descriptorFront + * The context to inspector identified by this descriptor. + * @param {WatcherFront} watcherFront + * If available, a reference to the related Watcher Front. + * @param {Object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ descriptorFront, watcherFront, commands }) { + super(); + + this.commands = commands; + this.descriptorFront = descriptorFront; + this.watcherFront = watcherFront; + this.rootFront = descriptorFront.client.mainRoot; + + this.store = createStore(reducer); + // Name of the store used when calling createProvider. + this.storeId = "target-store"; + + this._updateBrowserToolboxScope = + this._updateBrowserToolboxScope.bind(this); + + Services.prefs.addObserver( + BROWSERTOOLBOX_SCOPE_PREF, + this._updateBrowserToolboxScope + ); + // 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.isTabDescriptor) { + this.descriptorFront.on( + "remoteness-change", + this.onLocalTabRemotenessChange + ); + } + + if (this.isServerTargetSwitchingEnabled()) { + // XXX: Will only be used for local tab server side target switching if + // the first target is generated from the server. + this._onFirstTarget = new Promise(r => (this._resolveOnFirstTarget = r)); + } + + // 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(); + + // Listeners for target creation, destruction and selection + this._createListeners = new EventEmitter(); + this._destroyListeners = new EventEmitter(); + this._selectListeners = new EventEmitter(); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + this._onTargetSelected = this._onTargetSelected.bind(this); + // Bug 1675763: Watcher actor is not available in all situations yet. + if (this.watcherFront) { + this.watcherFront.on("target-available", this._onTargetAvailable); + this.watcherFront.on("target-destroyed", this._onTargetDestroyed); + } + + this.legacyImplementation = {}; + + // 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; + + // Tells us if we received the first top level target. + // If target switching is done on: + // * client side, this is done from startListening => _createFirstTarget + // and pull from the Descriptor front. + // * server side, this is also done from startListening, + // but we wait for the watcher actor to notify us about it + // via target-available-form avent. + this._gotFirstTopLevelTarget = false; + this._onResourceAvailable = this._onResourceAvailable.bind(this); + } + + get selectedTargetFront() { + return this.#selectedTargetFront || this.targetFront; + } + + /** + * Called fired when BROWSERTOOLBOX_SCOPE_PREF pref changes. + * This will enable/disable the full multiprocess debugging. + * When enabled we will watch for content process targets and debug all the processes. + * When disabled we will only watch for FRAME and WORKER and restrict ourself to parent process resources. + */ + _updateBrowserToolboxScope() { + const browserToolboxScope = Services.prefs.getCharPref( + BROWSERTOOLBOX_SCOPE_PREF + ); + if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { + // Force listening to new additional target types + this.startListening(); + } else if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_PARENTPROCESS) { + const disabledTargetTypes = [ + TargetCommand.TYPES.FRAME, + TargetCommand.TYPES.PROCESS, + ]; + // Force unwatching for additional targets types + // (we keep listening to workers) + // The related targets will be destroyed by the server + // and reported as destroyed to the frontend. + for (const type of disabledTargetTypes) { + this.stopListeningForType(type, { + isTargetSwitching: false, + isModeSwitching: true, + }); + } + } + } + + // 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) { + // We put the `commands` on the targetFront so it can be retrieved from any front easily. + // Without this, protocol.js fronts won't have any easy access to it. + // Ideally, Fronts would all be migrated to commands and we would no longer need this hack. + targetFront.commands = this.commands; + + // If the new target is a top level target, we are target switching. + // Target-switching is only triggered for "local-tab" browsing-context + // targets which should always have the topLevelTarget flag initialized + // on the server. + const isTargetSwitching = targetFront.isTopLevel; + const isFirstTarget = + targetFront.isTopLevel && !this._gotFirstTopLevelTarget; + + 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 TargetCommand", + 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 TargetCommand constructor. + if (targetFront.isTopLevel) { + // First report that all existing targets are destroyed + if (!isFirstTarget) { + this._destroyExistingTargetsOnTargetSwitching(); + } + + // Update the reference to the memoized top level target + this.targetFront = targetFront; + this.descriptorFront.setTarget(targetFront); + this.#selectedTargetFront = null; + + if (isFirstTarget && this.isServerTargetSwitchingEnabled()) { + this._gotFirstTopLevelTarget = true; + this._resolveOnFirstTarget(); + } + } + + // 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); + } + + if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) { + return; + } + + this.store.dispatch(registerTarget(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 && !isFirstTarget) { + await this.startListening({ isTargetSwitching: true }); + } + + // These two events are used by tests using the production codepath (i.e. disabling flags.testing) + // To be consumed by tests triggering frame navigations, spawning workers... + this.emit("processed-available-target", targetFront); + + if (isTargetSwitching) { + this.emit("switched-target", targetFront); + } + } + + _destroyExistingTargetsOnTargetSwitching() { + const destroyedTargets = []; + for (const target of this._targets) { + // We only consider the top level target to be switched + const isDestroyedTargetSwitching = target == this.targetFront; + const isServiceWorker = target.targetType === this.TYPES.SERVICE_WORKER; + const isPopup = target.targetForm.isPopup; + + // Never destroy the popup targets when the top level target is destroyed + // as the popup follow a different lifecycle. + // Also avoid destroying service worker targets for similar reason, + // unless this.destroyServiceWorkersOnNavigation is true. + if ( + !isPopup && + (!isServiceWorker || this.destroyServiceWorkersOnNavigation) + ) { + this._onTargetDestroyed(target, { + isTargetSwitching: isDestroyedTargetSwitching, + // Do not destroy service worker front as we may want to keep using it. + shouldDestroyTargetFront: !isServiceWorker, + }); + destroyedTargets.push(target); + } + } + + // Stop listening to legacy listeners as we now have to listen + // on the new target. + this.stopListening({ isTargetSwitching: true }); + + // Remove destroyed target from the cached target list. We don't simply clear the + // Map as SW targets might not have been destroyed (i.e. when destroyServiceWorkersOnNavigation + // is set to false). + for (const target of destroyedTargets) { + this._targets.delete(target); + } + } + + /** + * Function fired everytime a target is destroyed. + * + * This is called either: + * - via target-destroyed event fired by the WatcherFront, + * event which is a simple translation of the target-destroyed-form emitted by the WatcherActor. + * Watcher Actor emits this is various condition when the debugged target is meant to be destroyed: + * - the related target context is destroyed (tab closed, worker shut down, content process destroyed, ...), + * - when the DevToolsServerConnection used on the server side to communicate to the client is closed. + + * - by TargetCommand._onTargetAvailable, when a top level target switching happens and all previously + * registered target fronts should be destroyed. + + * - by the legacy Targets listeners, calling this method directly. + * This usecase is meant to be removed someday when all target targets are supported by the Watcher. + * (bug 1687459) + * + * @param {TargetFront} targetFront + * The target that just got destroyed. + * @param {Object} options + * @param {Boolean} [options.isTargetSwitching] + * To be set to true when this is about the top level target which is being replaced + * by a new one. + * The passed target should be still the one store in TargetCommand.targetFront + * and will be replaced via a call to onTargetAvailable with a new target front. + * @param {Boolean} [options.isModeSwitching] + * To be set to true when the target was destroyed was called as the result of a + * change to the devtools.browsertoolbox.scope pref. + * @param {Boolean} [options.shouldDestroyTargetFront] + * By default, the passed target front will be destroyed. But in some cases like + * legacy listeners for service workers we want to keep the front alive. + */ + _onTargetDestroyed( + targetFront, + { + isModeSwitching = false, + isTargetSwitching = false, + shouldDestroyTargetFront = true, + } = {} + ) { + // The watcher actor may notify us about the destruction of the top level target. + // But second argument to this method, isTargetSwitching is only passed from the frontend. + // So automatically toggle the isTargetSwitching flag for server side destructions + // only if that's about the existing top level target. + if (targetFront == this.targetFront) { + isTargetSwitching = true; + } + this._destroyListeners.emit(targetFront.targetType, { + targetFront, + isTargetSwitching, + isModeSwitching, + }); + this._targets.delete(targetFront); + + this.store.dispatch(unregisterTarget(targetFront)); + + // If the destroyed target was the selected one, we need to do some cleanup + if (this.#selectedTargetFront == targetFront) { + // If we're doing a targetSwitch, simply nullify #selectedTargetFront + if (isTargetSwitching) { + this.#selectedTargetFront = null; + } else { + // Otherwise we want to select the top level target + this.selectTarget(this.targetFront); + } + } + + if (shouldDestroyTargetFront) { + // When calling targetFront.destroy(), we will first call TargetFrontMixin.destroy, + // which will try to call `detach` RDP method. + // Unfortunately, this request will never complete in some cases like bfcache navigations. + // Because of that, the target front will never be completely destroy as it will prevent + // calling super.destroy and Front.destroy. + // Workaround that by manually calling Front class destroy method: + targetFront.baseFrontClassDestroy(); + + targetFront.destroy(); + + // Delete the attribute we set from _onTargetAvailable so that we avoid leaking commands + // if any target front is leaked. + delete targetFront.commands; + } + } + + /** + * + * @param {TargetFront} targetFront + */ + async _onTargetSelected(targetFront) { + if (this.#selectedTargetFront == targetFront) { + // Target is already selected, we can bail out. + return; + } + + this.#selectedTargetFront = targetFront; + const targetType = this.getTargetType(targetFront); + await this._selectListeners.emitAsync(targetType, { + targetFront, + }); + } + + _setListening(type, value) { + if (value) { + this._listenersStarted.add(type); + } else { + this._listenersStarted.delete(type); + } + } + + _isListening(type) { + return this._listenersStarted.has(type); + } + + /** + * Check if the watcher is currently supported. + * + * When no typeOrTrait is provided, we will only check that the watcher is + * available. + * + * When a typeOrTrait is provided, we will check for an explicit trait on the + * watcherFront that indicates either that: + * - a target type is supported + * - or that a custom trait is true + * + * @param {String} [targetTypeOrTrait] + * Optional target type or trait. + * @return {Boolean} true if the watcher is available and supports the + * optional targetTypeOrTrait + */ + hasTargetWatcherSupport(targetTypeOrTrait) { + if (targetTypeOrTrait) { + // Target types are also exposed as traits, where resource types are + // exposed under traits.resources (cf hasResourceWatcherSupport + // implementation). + return !!this.watcherFront?.traits[targetTypeOrTrait]; + } + + return !!this.watcherFront; + } + + /** + * 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 + * @param Boolean options.isTargetSwitching + * Set to true when this is called while a target switching happens. In such case, + * we won't register listener set on the Watcher Actor, but still register listeners + * set via Legacy Listeners. + */ + async startListening({ isTargetSwitching = false } = {}) { + // The first time we call this method, we pull the current top level target from the descriptor + if ( + !this.isServerTargetSwitchingEnabled() && + !this._gotFirstTopLevelTarget + ) { + await this._createFirstTarget(); + } + + // 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. + const types = this._computeTargetTypes(); + + 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 (!isTargetSwitching) { + await this.watcherFront.watchTargets(type); + } + } else if (LegacyTargetWatchers[type]) { + // Instantiate the legacy listener only once for each TargetCommand, and reuse it if we stop and restart listening + if (!this.legacyImplementation[type]) { + this.legacyImplementation[type] = new LegacyTargetWatchers[type]( + this, + this._onTargetAvailable, + this._onTargetDestroyed, + this.commands + ); + } + await this.legacyImplementation[type].listen(); + } else { + throw new Error(`Unsupported target type '${type}'`); + } + } + + if (!this._watchingDocumentEvent && !this.isDestroyed()) { + // We want to watch DOCUMENT_EVENT in order to update the url and title of target fronts, + // as the initial value that is set in them might be erroneous (if the target was + // created so early that the document url is still pointing to about:blank and the + // html hasn't be parsed yet, so we can't know the <title> content). + + this._watchingDocumentEvent = true; + await this.commands.resourceCommand.watchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + } + + if (this.isServerTargetSwitchingEnabled()) { + await this._onFirstTarget; + } + } + + async _createFirstTarget() { + // 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 = await this.descriptorFront.getTarget(); + this.targetFront.setTargetType(this.getTargetType(this.targetFront)); + this.targetFront.setIsTopLevel(true); + this._gotFirstTopLevelTarget = true; + + // See _onTargetAvailable. As this target isn't going through that method + // we have to replicate doing that here. + this.targetFront.commands = this.commands; + + // Add the top-level target to the list of targets. + this._targets.add(this.targetFront); + this.store.dispatch(registerTarget(this.targetFront)); + } + + _computeTargetTypes() { + let types = []; + + // We also check for watcher support as some xpcshell tests uses legacy APIs and don't support frames. + if ( + this.descriptorFront.isTabDescriptor && + this.hasTargetWatcherSupport(TargetCommand.TYPES.FRAME) + ) { + types = [TargetCommand.TYPES.FRAME]; + } else if (this.descriptorFront.isBrowserProcessDescriptor) { + const browserToolboxScope = Services.prefs.getCharPref( + BROWSERTOOLBOX_SCOPE_PREF + ); + if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { + types = TargetCommand.ALL_TYPES; + } + } + if (this.listenForWorkers && !types.includes(TargetCommand.TYPES.WORKER)) { + types.push(TargetCommand.TYPES.WORKER); + } + if ( + this.listenForWorkers && + !types.includes(TargetCommand.TYPES.SHARED_WORKER) + ) { + types.push(TargetCommand.TYPES.SHARED_WORKER); + } + if ( + this.listenForServiceWorkers && + !types.includes(TargetCommand.TYPES.SERVICE_WORKER) + ) { + types.push(TargetCommand.TYPES.SERVICE_WORKER); + } + + return types; + } + + /** + * Stop listening for targets from the server + * + * @param Object options + * @param Boolean options.isTargetSwitching + * Set to true when this is called while a target switching happens. In such case, + * we won't unregister listener set on the Watcher Actor, but still unregister + * listeners set via Legacy Listeners. + */ + stopListening({ isTargetSwitching = false } = {}) { + // As DOCUMENT_EVENT isn't using legacy listener, + // there is no need to stop and restart it in case of target switching. + if (this._watchingDocumentEvent && !isTargetSwitching) { + this.commands.resourceCommand.unwatchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + this._watchingDocumentEvent = false; + } + + for (const type of TargetCommand.ALL_TYPES) { + this.stopListeningForType(type, { isTargetSwitching }); + } + } + + /** + * Stop listening for targets of a given type from the server + * + * @param String type + * target type we want to stop listening for + * @param Object options + * @param Boolean options.isTargetSwitching + * Set to true when this is called while a target switching happens. In such case, + * we won't unregister listener set on the Watcher Actor, but still unregister + * listeners set via Legacy Listeners. + * @param Boolean options.isModeSwitching + * Set to true when this is called as the result of a change to the + * devtools.browsertoolbox.scope pref. + */ + stopListeningForType(type, { isTargetSwitching, isModeSwitching }) { + if (!this._isListening(type)) { + return; + } + 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 + // Also, TargetCommand.destroy may be called after the client is closed. + // So avoid calling the RDP method in that situation. + if (!isTargetSwitching && !this.watcherFront.isDestroyed()) { + this.watcherFront.unwatchTargets(type, { isModeSwitching }); + } + } else if (this.legacyImplementation[type]) { + this.legacyImplementation[type].unlisten({ + isTargetSwitching, + isModeSwitching, + }); + } else { + throw new Error(`Unsupported target type '${type}'`); + } + } + + getTargetType(target) { + const { typeName } = target; + if (typeName == "windowGlobalTarget") { + return TargetCommand.TYPES.FRAME; + } + + if ( + typeName == "contentProcessTarget" || + typeName == "parentProcessTarget" + ) { + return TargetCommand.TYPES.PROCESS; + } + + if (typeName == "workerDescriptor" || typeName == "workerTarget") { + if (target.isSharedWorker) { + return TargetCommand.TYPES.SHARED_WORKER; + } + + if (target.isServiceWorker) { + return TargetCommand.TYPES.SERVICE_WORKER; + } + + return TargetCommand.TYPES.WORKER; + } + + throw new Error("Unsupported target typeName: " + typeName); + } + + _matchTargetType(type, target) { + return type === target.targetType; + } + + _onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT + ) { + const { targetFront } = resource; + if (resource.title !== undefined && targetFront?.setTitle) { + targetFront.setTitle(resource.title); + } + if (resource.url !== undefined && targetFront?.setUrl) { + targetFront.setUrl(resource.url); + } + if ( + !resource.isFrameSwitching && + // `url` is set on the targetFront when we receive dom-loading, and `title` when + // `dom-interactive` is received. Here we're only updating the window title in + // the "newer" event. + resource.name === "dom-interactive" + ) { + // We just updated the targetFront title and url, force a refresh + // so that the EvaluationContext selector update them. + this.store.dispatch(refreshTargets()); + } + } + } + } + + /** + * Listen for the creation and/or destruction of target fronts matching one of the provided types. + * + * @param {Object} options + * @param {Array<String>} options.types + * The type of target to listen for. Constant of TargetCommand.TYPES. + * @param {Function} options.onAvailable + * Mandatory callback fired when a target has been just created or was already available. + * The function is called with a single object argument containing the following properties: + * - {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} options.onDestroyed + * Optional callback fired in case of target front destruction. + * The function is called with the same arguments than onAvailable. + * @param {Function} options.onSelected + * Optional callback fired when a given target is selected from the iframe picker + * The function is called with a single object argument containing the following properties: + * - {TargetFront} targetFront: The target Front + */ + async watchTargets(options = {}) { + const availableOptions = [ + "types", + "onAvailable", + "onDestroyed", + "onSelected", + ]; + const unsupportedKeys = Object.keys(options).filter( + key => !availableOptions.includes(key) + ); + if (unsupportedKeys.length) { + throw new Error( + `TargetCommand.watchTargets does not expect the following options: ${unsupportedKeys.join( + ", " + )}` + ); + } + + const { types, onAvailable, onDestroyed, onSelected } = options; + if (typeof onAvailable != "function") { + throw new Error( + "TargetCommand.watchTargets expects a function for the onAvailable option" + ); + } + + for (const type of types) { + if (!this._isValidTargetType(type)) { + throw new Error( + `TargetCommand.watchTargets invoked with an unknown type: "${type}"` + ); + } + } + + // 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 (onDestroyed) { + this._destroyListeners.on(type, onDestroyed); + } + if (onSelected) { + this._selectListeners.on(type, onSelected); + } + } + + 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(options = {}) { + const availableOptions = [ + "types", + "onAvailable", + "onDestroyed", + "onSelected", + ]; + const unsupportedKeys = Object.keys(options).filter( + key => !availableOptions.includes(key) + ); + if (unsupportedKeys.length) { + throw new Error( + `TargetCommand.unwatchTargets does not expect the following options: ${unsupportedKeys.join( + ", " + )}` + ); + } + + const { types, onAvailable, onDestroyed, onSelected } = options; + if (typeof onAvailable != "function") { + throw new Error( + "TargetCommand.unwatchTargets expects a function for the onAvailable option" + ); + } + + for (const type of types) { + if (!this._isValidTargetType(type)) { + throw new Error( + `TargetCommand.unwatchTargets invoked with an unknown type: "${type}"` + ); + } + + this._createListeners.off(type, onAvailable); + if (onDestroyed) { + this._destroyListeners.off(type, onDestroyed); + } + if (onSelected) { + this._selectListeners.off(type, onSelected); + } + } + 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 TargetCommand.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; + } + + /** + * Retrieve all the target fronts in the selected target tree (including the selected + * target itself). + * + * @param {Array<String>} types + * The types of target to retrieve. Array of TargetCommand.TYPES + * @return {Promise<Array<TargetFront>>} Promise that resolves to an array of target fronts. + */ + async getAllTargetsInSelectedTargetTree(types) { + const allTargets = this.getAllTargets(types); + if (this.isTopLevelTargetSelected()) { + return allTargets; + } + + const targets = [this.selectedTargetFront]; + for (const target of allTargets) { + const isInSelectedTree = await target.isTargetAnAncestor( + this.selectedTargetFront + ); + + if (isInSelectedTree) { + targets.push(target); + } + } + return targets; + } + + /** + * For all the target fronts of given types, retrieve all the target-scoped fronts of the given types. + * + * @param {Array<String>} targetTypes + * The types of target to iterate over. Constant of TargetCommand.TYPES. + * @param {String} frontType + * The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",... + * @param {Object} options + * @param {Boolean} options.onlyInSelectedTargetTree + * Set to true to only get the fronts for targets who are in the "targets tree" + * of the selected target. + */ + async getAllFronts( + targetTypes, + frontType, + { onlyInSelectedTargetTree = false } = {} + ) { + if (!Array.isArray(targetTypes) || !targetTypes?.length) { + throw new Error("getAllFronts expects a non-empty array of target types"); + } + const promises = []; + const targets = !onlyInSelectedTargetTree + ? this.getAllTargets(targetTypes) + : await this.getAllTargetsInSelectedTargetTree(targetTypes); + for (const target of targets) { + // For still-attaching worker targets, the thread or console front may not yet be available, + // whereas TargetMixin.getFront will throw if the actorID isn't available in targetForm. + // Also ignore destroyed targets. For some reason the previous methods fetching targets + // can sometime return destroyed targets. + if ( + (frontType == "thread" && !target.targetForm.threadActor) || + (frontType == "console" && !target.targetForm.consoleActor) || + target.isDestroyed() + ) { + continue; + } + + promises.push(target.getFront(frontType)); + } + return Promise.all(promises); + } + + /** + * This function is triggered by an event sent by the TabDescriptor when + * the tab navigates to a distinct process. + * + * @param TargetFront targetFront + * The WindowGlobalTargetFront instance that navigated to another process + */ + async onLocalTabRemotenessChange(targetFront) { + if (this.isServerTargetSwitchingEnabled()) { + // For server-side target switching, everything will be handled by the + // _onTargetAvailable callback. + return; + } + + // TabDescriptor may emit the event with a null targetFront, interpret that as if the previous target + // has already been destroyed + if (targetFront) { + // Wait for the target to be destroyed so that LocalTabCommandsFactory clears its memoized target for this tab + await targetFront.once("target-destroyed"); + } + + // Fetch the new target from the descriptor. + const newTarget = await this.descriptorFront.getTarget(); + + // If a navigation happens while we try to get the target for the page that triggered + // the remoteness change, `getTarget` will return null. In such case, we'll get the + // "next" target through onTargetAvailable so it's safe to bail here. + if (!newTarget) { + console.warn( + `Couldn't get the target for descriptor ${this.descriptorFront.actorID}` + ); + return; + } + + this.switchToTarget(newTarget); + } + + /** + * Reload the current top level target. + * This only works for targets inheriting from WindowGlobalTarget. + * + * @param {Boolean} bypassCache + * If true, the reload will be forced to bypass any cache. + */ + async reloadTopLevelTarget(bypassCache = false) { + if (!this.descriptorFront.traits.supportsReloadDescriptor) { + throw new Error("The top level target doesn't support being reloaded"); + } + + // Wait for the next DOCUMENT_EVENT's dom-complete event + // Wait for waitForNextResource completion before reloading, otherwise we might miss the dom-complete event. + // This can happen if `ResourceCommand.watchResources` made by `waitForNextResource` is still pending + // while the reload already started and finished loading the document early. + const { onResource: onReloaded } = + await this.commands.resourceCommand.waitForNextResource( + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "dom-complete"; + }, + } + ); + + await this.descriptorFront.reloadDescriptor({ bypassCache }); + + await onReloaded; + } + + /** + * 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) { + // Notify about this new target to creation listeners + // _onTargetAvailable will also destroy all previous target before notifying about this new one. + await this._onTargetAvailable(newTarget); + } + + /** + * Called when the user selects a frame in the iframe picker. + * + * @param {WindowGlobalTargetFront} targetFront + * The target front we want the toolbox to focus on. + */ + selectTarget(targetFront) { + return this._onTargetSelected(targetFront); + } + + /** + * Returns true if the top-level frame is the selected one + * + * @returns {Boolean} + */ + isTopLevelTargetSelected() { + return this.selectedTargetFront === this.targetFront; + } + + /** + * Returns true if a non top-level frame is the selected one in the iframe picker. + * + * @returns {Boolean} + */ + isNonTopLevelTargetSelected() { + return this.selectedTargetFront !== this.targetFront; + } + + isTargetRegistered(targetFront) { + return this._targets.has(targetFront); + } + + getParentTarget(targetFront) { + // Note that there are edgecases: + // * Until bug 1741927 is fixed and we remove non-EFT codepath entirely, + // we may receive a `parentInnerWindowId` that doesn't relate to any target. + // This happens when the parent document of the targetFront is a document loaded in the + // same process as its parent document. In such scenario, and only when EFT is disabled, + // we won't instantiate a target for the parent document of the targetFront. + // * `parentInnerWindowId` could be null in some case like for tabs in the MBT + // we should report the top level target as parent. That's what `getParentWindowGlobalTarget` does. + // Once we can stop using getParentWindowGlobalTarget for the other edgecase we will be able to + // replace it with such fallback: `return this.targetFront;`. + // browser_target_command_frames.js will help you get things right. + const { parentInnerWindowId } = targetFront.targetForm; + if (parentInnerWindowId) { + const targets = this.getAllTargets([TargetCommand.TYPES.FRAME]); + const parent = targets.find( + target => target.innerWindowId == parentInnerWindowId + ); + // Until EFT is the only codepath supported (bug 1741927), we will fallback to `getParentWindowGlobalTarget` + // as we may not have a target if the parent is an iframe running in the same process as its parent. + if (parent) { + return parent; + } + } + + // Note that all callsites which care about FRAME additional target + // should all have a toolbox using the watcher actor. + // It should be: MBT, regular tab toolbox and web extension. + // The others which still don't support watcher don't spawn FRAME targets: + // browser content toolbox and service workers. + + return this.watcherFront.getParentWindowGlobalTarget( + targetFront.browsingContextID + ); + } + + isDestroyed() { + return this._isDestroyed; + } + + isServerTargetSwitchingEnabled() { + if (this.descriptorFront.isServerTargetSwitchingEnabled) { + return this.descriptorFront.isServerTargetSwitchingEnabled(); + } + return false; + } + + _isValidTargetType(type) { + return this.ALL_TYPES.includes(type); + } + + destroy() { + this.stopListening(); + this._createListeners.off(); + this._destroyListeners.off(); + this._selectListeners.off(); + + this.#selectedTargetFront = null; + this._isDestroyed = true; + + Services.prefs.removeObserver( + BROWSERTOOLBOX_SCOPE_PREF, + this._updateBrowserToolboxScope + ); + } +} + +/** + * All types of target: + */ +TargetCommand.TYPES = TargetCommand.prototype.TYPES = { + PROCESS: "process", + FRAME: "frame", + WORKER: "worker", + SHARED_WORKER: "shared_worker", + SERVICE_WORKER: "service_worker", +}; +TargetCommand.ALL_TYPES = TargetCommand.prototype.ALL_TYPES = Object.values( + TargetCommand.TYPES +); + +const LegacyTargetWatchers = {}; +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.PROCESS, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js" +); +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.WORKER, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js" +); +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.SHARED_WORKER, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js" +); +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.SERVICE_WORKER, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js" +); + +module.exports = TargetCommand; diff --git a/devtools/shared/commands/target/tests/browser.ini b/devtools/shared/commands/target/tests/browser.ini new file mode 100644 index 0000000000..b28e700de0 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser.ini @@ -0,0 +1,48 @@ +[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/highlighter-test-actor.js + head.js + simple_document.html + incremental-js-value-script.sjs + fission_document.html + fission_iframe.html + test_service_worker.js + test_sw_page.html + test_sw_page_worker.js + test_worker.js + +[browser_target_command_bfcache.js] +[browser_target_command_browser_workers.js] +[browser_target_command_detach.js] +[browser_target_command_frames_popups.js] +skip-if = + win10_2004 && debug && fission && socketprocess_networking # high frequency intermittent +[browser_target_command_frames_reload_server_side_targets.js] +skip-if = !fission +[browser_target_command_frames.js] +[browser_target_command_getAllTargets.js] +[browser_target_command_invalid_api_usage.js] +[browser_target_command_scope_flag.js] +[browser_target_command_processes.js] +[browser_target_command_reload.js] +[browser_target_command_service_workers.js] +skip-if = + http3 # Bug 1781324 +[browser_target_command_service_workers_navigation.js] +skip-if = + os == "linux" && bits == 64 # Bug 1726270 + os == "win" && bits == 64 # Bug 1726270 + os == "mac" && fission # Bug 1726270 +[browser_target_command_switchToTarget.js] +[browser_target_command_tab_workers.js] +[browser_target_command_tab_workers_bfcache_navigation.js] +skip-if = debug # Bug 1721859 +[browser_target_command_various_descriptors.js] +skip-if = + os == "linux" && bits == 64 && !debug #Bug 1701056 +[browser_target_command_watchTargets.js] +[browser_watcher_actor_getter_caching.js] diff --git a/devtools/shared/commands/target/tests/browser_target_command_bfcache.js b/devtools/shared/commands/target/tests/browser_target_command_bfcache.js new file mode 100644 index 0000000000..a5deeb9260 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_bfcache.js @@ -0,0 +1,499 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API when bfcache navigations happen + +const TEST_COM_URL = URL_ROOT_SSL + "simple_document.html"; + +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); + + info("## Test with bfcache in parent DISABLED"); + await pushPref("fission.bfcacheInParent", false); + await testTopLevelNavigations(false); + await testIframeNavigations(false); + await testTopLevelNavigationsOnDocumentWithIframe(false); + + // bfcacheInParent only works if sessionHistoryInParent is enable + // so only test it if both settings are enabled. + // (it looks like sessionHistoryInParent is enabled by default when fission is enabled) + if (Services.appinfo.sessionHistoryInParent) { + info("## Test with bfcache in parent ENABLED"); + await pushPref("fission.bfcacheInParent", true); + await testTopLevelNavigations(true); + await testIframeNavigations(true); + await testTopLevelNavigationsOnDocumentWithIframe(true); + } +}); + +async function testTopLevelNavigations(bfcacheInParent) { + info(" # Test TOP LEVEL navigations"); + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_COM_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok(targetFront.isTopLevel, "all targets of this test are top level"); + targets.push(targetFront); + }; + const destroyedTargets = []; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok(targetFront.isTopLevel, "all targets of this test are top level"); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + is(targets.length, 1, "retrieved only the top level target"); + is(targets[0], targetCommand.targetFront, "the target is the top level one"); + is( + destroyedTargets.length, + 0, + "We get no destruction when calling watchTargets" + ); + ok( + targets[0].targetForm.followWindowGlobalLifeCycle, + "the first server side target follows the WindowGlobal lifecycle, when server target switching is enabled" + ); + + // Navigate to the same page with query params + info("Load the second page"); + const secondPageUrl = TEST_COM_URL + "?second-load"; + const previousBrowsingContextID = gBrowser.selectedBrowser.browsingContext.id; + ok( + previousBrowsingContextID, + "Fetch the tab's browsing context id before navigation" + ); + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + secondPageUrl + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, secondPageUrl); + await onLoaded; + + // Assert BrowsingContext changes as it impact the behavior of targets + if (bfcacheInParent) { + isnot( + previousBrowsingContextID, + gBrowser.selectedBrowser.browsingContext.id, + "When bfcacheInParent is enabled, same-origin navigations spawn new BrowsingContext" + ); + } else { + is( + previousBrowsingContextID, + gBrowser.selectedBrowser.browsingContext.id, + "When bfcacheInParent is disabled, same-origin navigations re-use the same BrowsingContext" + ); + } + + // Same-origin navigations also spawn a new top level target + await waitFor( + () => targets.length == 2, + "wait for the next top level target" + ); + is( + targets[1], + targetCommand.targetFront, + "the second target is the top level one" + ); + // As targetFront.url isn't reliable and might be about:blank, + // try to assert that we got the right target via other means. + // outerWindowID should change when navigating to another process, + // while it would stay equal for in-process navigations. + is( + targets[1].outerWindowID, + gBrowser.selectedBrowser.outerWindowID, + "the second target is for the second page" + ); + ok( + targets[1].targetForm.followWindowGlobalLifeCycle, + "the new server side target follows the WindowGlobal lifecycle" + ); + ok(targets[0].isDestroyed(), "the first target is destroyed"); + is(destroyedTargets.length, 1, "We get one target being destroyed..."); + is(destroyedTargets[0], targets[0], "...and that's the first one"); + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target + info("Go back to the first page"); + gBrowser.selectedBrowser.goBack(); + + await waitFor( + () => targets.length == 3, + "wait for the next top level target" + ); + is( + targets[2], + targetCommand.targetFront, + "the third target is the top level one" + ); + // Here as this is revived from cache, the url should always be correct + is(targets[2].url, TEST_COM_URL, "the third target is for the first url"); + ok( + targets[2].targetForm.followWindowGlobalLifeCycle, + "the third target for bfcache navigations is following the WindowGlobal lifecycle" + ); + ok(targets[1].isDestroyed(), "the second target is destroyed"); + is( + destroyedTargets.length, + 2, + "We get one additional target being destroyed..." + ); + is(destroyedTargets[1], targets[1], "...and that's the second one"); + + // Wait for full attach in order to having breaking any pending requests + // when navigating to another page and switching to new process and target. + await waitForAllTargetsToBeAttached(targetCommand); + + // Go forward and resurect the second page, this should also be a bfcache navigation, and, + // get a new target. + info("Go forward to the second page"); + + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = bfcacheInParent + ? new Promise(resolve => { + targetCommand.on( + "processed-available-target", + function onProcessedAvailableTarget(targetFront) { + if (targetFront === targets[3]) { + resolve(); + targetCommand.off( + "processed-available-target", + onProcessedAvailableTarget + ); + } + } + ); + }) + : null; + + gBrowser.selectedBrowser.goForward(); + + await waitFor( + () => targets.length == 4, + "wait for the next top level target" + ); + is( + targets[3], + targetCommand.targetFront, + "the 4th target is the top level one" + ); + // Same here, as the document is revived from the cache, the url should always be correct + is(targets[3].url, secondPageUrl, "the 4th target is for the second url"); + ok( + targets[3].targetForm.followWindowGlobalLifeCycle, + "the 4th target for bfcache navigations is following the WindowGlobal lifecycle" + ); + ok(targets[2].isDestroyed(), "the third target is destroyed"); + is( + destroyedTargets.length, + 3, + "We get one additional target being destroyed..." + ); + is(destroyedTargets[2], targets[2], "...and that's the third one"); + + // Wait for full attach in order to having breaking any pending requests + // when navigating to another page and switching to new process and target. + await waitForAllTargetsToBeAttached(targetCommand); + await onNewTargetProcessed; + + await waitForAllTargetsToBeAttached(targetCommand); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testTopLevelNavigationsOnDocumentWithIframe(bfcacheInParent) { + info(" # Test TOP LEVEL navigations on document with iframe"); + // Create a TargetCommand for a given test tab + const tab = + await addTab(`https://example.com/document-builder.sjs?id=top&html= + <h1>Top level</h1> + <iframe src="${encodeURIComponent( + "https://example.com/document-builder.sjs?id=iframe&html=<h2>In iframe</h2>" + )}"> + </iframe>`); + const getLocationIdParam = url => + new URLSearchParams(new URL(url).search).get("id"); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + targets.push(targetFront); + }; + const destroyedTargets = []; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + if (isEveryFrameTargetEnabled()) { + is( + targets.length, + 2, + "retrieved targets for top level and iframe documents" + ); + is( + targets[0], + targetCommand.targetFront, + "the target is the top level one" + ); + is( + getLocationIdParam(targets[1].url), + "iframe", + "the second target is the iframe one" + ); + } else { + is(targets.length, 1, "retrieved only the top level target"); + is( + targets[0], + targetCommand.targetFront, + "the target is the top level one" + ); + } + + is( + destroyedTargets.length, + 0, + "We get no destruction when calling watchTargets" + ); + + info("Navigate to a new page"); + let targetCountBeforeNavigation = targets.length; + const secondPageUrl = `https://example.com/document-builder.sjs?html=second`; + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + secondPageUrl + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, secondPageUrl); + await onLoaded; + + // Same-origin navigations also spawn a new top level target + await waitFor( + () => targets.length == targetCountBeforeNavigation + 1, + "wait for the next top level target" + ); + is( + targets.at(-1), + targetCommand.targetFront, + "the new target is the top level one" + ); + + ok(targets[0].isDestroyed(), "the first target is destroyed"); + if (isEveryFrameTargetEnabled()) { + ok(targets[1].isDestroyed(), "the second target is destroyed"); + is(destroyedTargets.length, 2, "The two targets were destroyed"); + } else { + is(destroyedTargets.length, 1, "Only one target was destroyed"); + } + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target (or 2 if EFT is enabled) + targetCountBeforeNavigation = targets.length; + info("Go back to the first page"); + gBrowser.selectedBrowser.goBack(); + + await waitFor( + () => + targets.length === + targetCountBeforeNavigation + (isEveryFrameTargetEnabled() ? 2 : 1), + "wait for the next top level target" + ); + + if (isEveryFrameTargetEnabled()) { + await waitFor(() => targets.at(-2).url && targets.at(-1).url); + is( + getLocationIdParam(targets.at(-2).url), + "top", + "the first new target is for the top document…" + ); + is( + getLocationIdParam(targets.at(-1).url), + "iframe", + "…and the second one is for the iframe" + ); + } else { + is( + getLocationIdParam(targets.at(-1).url), + "top", + "the new target is for the first url" + ); + } + + ok( + targets[targetCountBeforeNavigation - 1].isDestroyed(), + "the target for the second page is destroyed" + ); + is( + destroyedTargets.length, + targetCountBeforeNavigation, + "We get one additional target being destroyed…" + ); + is( + destroyedTargets.at(-1), + targets[targetCountBeforeNavigation - 1], + "…and that's the second page one" + ); + + await waitForAllTargetsToBeAttached(targetCommand); + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testIframeNavigations() { + info(" # Test IFRAME navigations"); + // Create a TargetCommand for a given test tab + const tab = await addTab( + `http://example.org/document-builder.sjs?html=<iframe src="${TEST_COM_URL}"></iframe>` + ); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable }); + + // When fission/EFT is off, there isn't much to test for iframes as they are debugged + // when the unique top level target + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + is( + targets.length, + 1, + "Without fission/EFT, there is only the top level target" + ); + await commands.destroy(); + return; + } + is(targets.length, 2, "retrieved the top level and the iframe targets"); + is( + targets[0], + targetCommand.targetFront, + "the first target is the top level one" + ); + is(targets[1].url, TEST_COM_URL, "the second target is the iframe one"); + + // Navigate to the same page with query params + info("Load the second page"); + const secondPageUrl = TEST_COM_URL + "?second-load"; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [secondPageUrl], + function (url) { + const iframe = content.document.querySelector("iframe"); + iframe.src = url; + } + ); + + await waitFor(() => targets.length == 3, "wait for the next target"); + is(targets[2].url, secondPageUrl, "the second target is for the second url"); + ok(targets[1].isDestroyed(), "the first target is destroyed"); + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target + info("Go back to the first page"); + const iframeBrowsingContext = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + const iframe = content.document.querySelector("iframe"); + return iframe.browsingContext; + } + ); + await SpecialPowers.spawn(iframeBrowsingContext, [], function () { + content.history.back(); + }); + + await waitFor(() => targets.length == 4, "wait for the next target"); + is(targets[3].url, TEST_COM_URL, "the third target is for the first url"); + ok(targets[2].isDestroyed(), "the second target is destroyed"); + + // Go forward and resurect the second page, this should also be a bfcache navigation, and, + // get a new target. + info("Go forward to the second page"); + await SpecialPowers.spawn(iframeBrowsingContext, [], function () { + content.history.forward(); + }); + + await waitFor(() => targets.length == 5, "wait for the next target"); + is(targets[4].url, secondPageUrl, "the 4th target is for the second url"); + ok(targets[3].isDestroyed(), "the third target is destroyed"); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + await waitForAllTargetsToBeAttached(targetCommand); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js new file mode 100644 index 0000000000..181cfa2614 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_browser_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 TargetCommand API around workers + +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 TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // 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 tab = await addTab(FISSION_TEST_URL); + + info("Test TargetCommand 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 commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Very naive sanity check against getAllTargets([workerType]) + info("Check that getAllTargets returned the expected targets"); + const workers = await targetCommand.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 targetCommand.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 targetCommand.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 targetCommand.getAllTargets([TYPES.WORKER]); + const sharedWorkers2 = await targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const serviceWorkers2 = await targetCommand.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 topLevelTarget = await commands.targetCommand.targetFront; + 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 == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [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` + ); + } + + targetCommand.unwatchTargets({ + types: [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; + } + targetCommand.unwatchTargets({ + types: [TYPES.WORKER], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.WORKER], + onAvailable: 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" + ); + is( + workerTarget.name, + "test_worker.js#second", + "The worker target has the expected name" + ); + + const workers3 = await targetCommand.getAllTargets([TYPES.WORKER]); + const hasWorker2 = workers3.find( + ({ url }) => url == `${CHROME_WORKER_URL}#second` + ); + ok(hasWorker2, "retrieve the target for tab via getAllTargets"); + + info( + "Check that terminating the worker does trigger the onDestroyed callback" + ); + const onWorkerDestroyed = new Promise(resolve => { + const emptyFn = () => {}; + const onDestroyed = ({ targetFront }) => { + targetCommand.unwatchTargets({ + types: [TYPES.WORKER], + onAvailable: emptyFn, + onDestroyed, + }); + resolve(targetFront); + }; + + targetCommand.watchTargets({ + types: [TYPES.WORKER], + onAvailable: emptyFn, + onDestroyed, + }); + }); + worker2.terminate(); + const workerTargetFront = await onWorkerDestroyed; + ok(true, "onDestroyed was called when the worker was terminated"); + + workerTargetFront.isTopLevel; + ok( + true, + "isTopLevel can be called on the target front after onDestroyed was called" + ); + + workerTargetFront.name; + ok( + true, + "name can be accessed on the target front after onDestroyed was called" + ); + + targetCommand.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(commands.client); + + await commands.destroy(); + + 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/commands/target/tests/browser_target_command_detach.js b/devtools/shared/commands/target/tests/browser_target_command_detach.js new file mode 100644 index 0000000000..a0056cd7a5 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_detach.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's when detaching the top target +// +// Do this with the "remote tab" codepath, which will avoid +// destroying the DevToolsClient when the target is destroyed. +// Otherwise, with "local tab", the client is closed and everything is destroy +// on both client and server side. + +const TEST_URL = "data:text/html,test-page"; + +add_task(async function () { + info(" ### Test detaching the top target"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + + info("Create a first commands, which will destroy its top target"); + const commands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId + ); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + info("Call any target front method, to ensure it works fine"); + await targetCommand.targetFront.focus(); + + // Destroying the target front should end up calling "WindowGlobalTargetActor.detach" + // which should destroy the target on the server side + await targetCommand.targetFront.destroy(); + + info( + "Now create a second commands after destroy, to see if we can spawn a new, functional target" + ); + const secondCommands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId, + { + client: commands.client, + } + ); + const secondTargetCommand = secondCommands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await secondTargetCommand.startListening(); + + info("Call any target front method, to ensure it works fine"); + await secondTargetCommand.targetFront.focus(); + + BrowserTestUtils.removeTab(tab); + + info("Close the two commands"); + await commands.destroy(); + await secondCommands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames.js b/devtools/shared/commands/target/tests/browser_target_command_frames.js new file mode 100644 index 0000000000..bfa297801a --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames.js @@ -0,0 +1,649 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around frames + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; +const SECOND_PAGE_URL = "https://example.org/document-builder.sjs?html=org"; + +const PID_REGEXP = /^\d+$/; + +add_task(async function () { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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); + + // Test fetching the frames from the main process descriptor + await testBrowserFrames(); + + // Test fetching the frames from a tab descriptor + await testTabFrames(); + + // Test what happens with documents running in the parent process + await testOpeningOnParentProcessDocument(); + await testNavigationToParentProcessDocument(); + + // Test what happens with about:blank documents + await testOpeningOnAboutBlankDocument(); + await testNavigationToAboutBlankDocument(); + + await testNestedIframes(); +}); + +async function testOpeningOnParentProcessDocument() { + info("Test opening against a parent process document"); + const tab = await addTab("about:robots"); + is( + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, "about:robots", "target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "the target is the current top level one" + ); + + await commands.destroy(); +} + +async function testNavigationToParentProcessDocument() { + info("Test navigating to parent process document"); + const firstLocation = "data:text/html,foo"; + const secondLocation = "about:robots"; + + const tab = await addTab(firstLocation); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + // When the first top level target is created from the server, + // `startListening` emits a spurious switched-target event + // which isn't necessarily emited before it resolves. + // So ensure waiting for it, otherwise we may resolve too eagerly + // in our expected listener. + const onSwitchedTarget1 = targetCommand.once("switched-target"); + await targetCommand.startListening(); + info("wait for first top level target"); + await onSwitchedTarget1; + + const firstTarget = targetCommand.targetFront; + is(firstTarget.url, firstLocation, "first target url is correct"); + + info("Navigate to a parent process page"); + const onSwitchedTarget = targetCommand.once("switched-target"); + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.loadURIString(browser, secondLocation); + await onLoaded; + is( + browser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + await onSwitchedTarget; + isnot(targetCommand.targetFront, firstTarget, "got a new target"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, secondLocation, "second target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "second target is the current top level one" + ); + + await commands.destroy(); +} + +async function testOpeningOnAboutBlankDocument() { + info("Test opening against about:blank document"); + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, "about:blank", "target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "the target is the current top level one" + ); + + await commands.destroy(); +} + +async function testNavigationToAboutBlankDocument() { + info("Test navigating to about:blank"); + const firstLocation = "data:text/html,foo"; + const secondLocation = "about:blank"; + + const tab = await addTab(firstLocation); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + // When the first top level target is created from the server, + // `startListening` emits a spurious switched-target event + // which isn't necessarily emited before it resolves. + // So ensure waiting for it, otherwise we may resolve too eagerly + // in our expected listener. + const onSwitchedTarget1 = targetCommand.once("switched-target"); + await targetCommand.startListening(); + info("wait for first top level target"); + await onSwitchedTarget1; + + const firstTarget = targetCommand.targetFront; + is(firstTarget.url, firstLocation, "first target url is correct"); + + info("Navigate to about:blank page"); + const onSwitchedTarget = targetCommand.once("switched-target"); + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.loadURIString(browser, secondLocation); + await onLoaded; + + await onSwitchedTarget; + isnot(targetCommand.targetFront, firstTarget, "got a new target"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, secondLocation, "second target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "second target is the current top level one" + ); + + await commands.destroy(); +} + +async function testBrowserFrames() { + info("Test TargetCommand against frames via the parent process target"); + + const aboutBlankTab = await addTab("about:blank"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Very naive sanity check against getAllTargets([frame]) + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + const hasBrowserDocument = frames.find( + frameTarget => frameTarget.url == window.location.href + ); + ok(hasBrowserDocument, "retrieve the target for the browser document"); + + const hasAboutBlankDocument = frames.find( + frameTarget => + frameTarget.browsingContextID == + aboutBlankTab.linkedBrowser.browsingContext.id + ); + ok(hasAboutBlankDocument, "retrieve the target for the about:blank tab"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames2 = await targetCommand.getAllTargets([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 topLevelTarget = targetCommand.targetFront; + + const noParentTarget = await topLevelTarget.getParentTarget(); + is(noParentTarget, null, "The top level target has no parent target"); + + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + ok( + PID_REGEXP.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ types: [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` + ); + } + + async function addTabAndAssertNewTarget(url) { + const previousTargetCount = targets.length; + const tab = await addTab(url); + await waitFor( + () => targets.length == previousTargetCount + 1, + "Wait for all expected targets after tab opening" + ); + is( + targets.length, + previousTargetCount + 1, + "Opening a tab reported a new frame" + ); + const newTabTarget = targets.at(-1); + is(newTabTarget.url, url, "This frame target is about the new tab"); + // Internaly, the tab, which uses a <browser type='content'> element is considered detached from their owner document + // and so the target is having a null parentInnerWindowId. But the framework will attach all non-top-level targets + // as children of the top level. + const tabParentTarget = await newTabTarget.getParentTarget(); + is( + tabParentTarget, + targetCommand.targetFront, + "tab's WindowGlobal/BrowsingContext is detached and has no parent, but we report them as children of the top level target" + ); + + const frames3 = await targetCommand.getAllTargets([TYPES.FRAME]); + const hasTabDocument = frames3.find(target => target.url == url); + ok(hasTabDocument, "retrieve the target for tab via getAllTargets"); + + return tab; + } + + info("Open a tab loaded in content process"); + await addTabAndAssertNewTarget("data:text/html,content-process-page"); + + info("Open a tab loaded in the parent process"); + const parentProcessTab = await addTabAndAssertNewTarget("about:robots"); + is( + parentProcessTab.linkedBrowser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + info("Open a new content window via window.open"); + info("First open a tab on .org domain"); + const tabUrl = "https://example.org/document-builder.sjs?html=org"; + await addTabAndAssertNewTarget(tabUrl); + const previousTargetCount = targets.length; + + info("Then open a popup on .com domain"); + const popupUrl = "https://example.com/document-builder.sjs?html=com"; + const onPopupOpened = BrowserTestUtils.waitForNewTab(gBrowser, popupUrl); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [popupUrl], async url => { + content.window.open(url, "_blank"); + }); + await onPopupOpened; + + await waitFor( + () => targets.length == previousTargetCount + 1, + "Wait for all expected targets after window.open()" + ); + is( + targets.length, + previousTargetCount + 1, + "Opening a new content window reported a new frame" + ); + is( + targets.at(-1).url, + popupUrl, + "This frame target is about the new content window" + ); + + // About:blank are a bit special because we ignore a transcient about:blank + // document when navigating to another process. But we should not ignore + // tabs, loading a real, final about:blank document. + info("Open a tab with about:blank"); + await addTabAndAssertNewTarget("about:blank"); + + // Until we start spawning target for all WindowGlobals, + // including the one running in the same process as their parent, + // we won't create dedicated target for new top level windows. + // Instead, these document will be debugged via the ParentProcessTargetActor. + info("Open a top level chrome window"); + const expectedTargets = targets.length; + const chromeWindow = Services.ww.openWindow( + null, + "about:robots", + "_blank", + "chrome", + null + ); + await wait(250); + is( + targets.length, + expectedTargets, + "New top level window shouldn't spawn new target" + ); + chromeWindow.close(); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + targetCommand.destroy(); + await waitForAllTargetsToBeAttached(targetCommand); + + await commands.destroy(); +} + +async function testTabFrames(mainRoot) { + info("Test TargetCommand against frames via a tab target"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(FISSION_TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + // When fission is enabled, we also get the remote example.org iframe. + const expectedFramesCount = + isFissionEnabled() || isEveryFrameTargetEnabled() ? 2 : 1; + is( + frames.length, + expectedFramesCount, + "retrieved the expected number of targets" + ); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const topLevelTarget = targetCommand.targetFront; + const onAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + PID_REGEXP.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.push({ targetFront, isTargetSwitching }); + }; + const onDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + destroyedTargets.push({ targetFront, isTargetSwitching }); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + is(destroyedTargets.length, 0, "Should be no destroyed target initialy"); + + for (const frame of frames) { + ok( + targets.find(({ targetFront }) => targetFront === frame), + "frame " + frame.actorID + " target is the same via watchTargets" + ); + } + is( + targets[0].targetFront.url, + FISSION_TEST_URL, + "First target should be the top document one" + ); + is( + targets[0].targetFront.isTopLevel, + true, + "First target is a top level one" + ); + is( + !targets[0].isTargetSwitching, + true, + "First target is not considered as a target switching" + ); + const noParentTarget = await targets[0].targetFront.getParentTarget(); + is(noParentTarget, null, "The top level target has no parent target"); + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + targets[1].targetFront.url, + IFRAME_URL, + "Second target should be the iframe one" + ); + is( + !targets[1].targetFront.isTopLevel, + true, + "Iframe target isn't top level" + ); + is( + !targets[1].isTargetSwitching, + true, + "Iframe target isn't a target swich" + ); + const parentTarget = await targets[1].targetFront.getParentTarget(); + is( + parentTarget, + targets[0].targetFront, + "The parent target for the iframe is the top level target" + ); + } + + // Before navigating to another process, ensure cleaning up everything from the first page + await waitForAllTargetsToBeAttached(targetCommand); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + + info("Navigate to another domain and process (if fission is enabled)"); + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = targetCommand.once("processed-available-target"); + + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.loadURIString(browser, SECOND_PAGE_URL); + await onLoaded; + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + const afterNavigationFramesCount = 3; + await waitFor( + () => targets.length == afterNavigationFramesCount, + "Wait for all expected targets after navigation" + ); + is( + targets.length, + afterNavigationFramesCount, + "retrieved all targets after navigation" + ); + // As targetFront.url isn't reliable and might be about:blank, + // try to assert that we got the right target via other means. + // outerWindowID should change when navigating to another process, + // while it would stay equal for in-process navigations. + is( + targets[2].targetFront.outerWindowID, + browser.outerWindowID, + "The new target should be the newly loaded document" + ); + is( + targets[2].isTargetSwitching, + true, + "and should be flagged as a target switching" + ); + + is( + destroyedTargets.length, + 2, + "The two existing targets should be destroyed" + ); + is( + destroyedTargets[0].targetFront, + targets[1].targetFront, + "The first destroyed should be the iframe one" + ); + is( + destroyedTargets[0].isTargetSwitching, + false, + "the target destruction is not flagged as target switching for iframes" + ); + is( + destroyedTargets[1].targetFront, + targets[0].targetFront, + "The second destroyed should be the previous top level one (because it is delayed to be fired *after* will-navigate)" + ); + is( + destroyedTargets[1].isTargetSwitching, + true, + "the target destruction is flagged as target switching" + ); + } else { + await waitFor( + () => targets.length == 2, + "Wait for all expected targets after navigation" + ); + is( + destroyedTargets.length, + 1, + "with JSWindowActor based target, the top level target is destroyed" + ); + is( + targetCommand.targetFront, + targets[1].targetFront, + "we got a new target" + ); + ok( + !targetCommand.targetFront.isDestroyed(), + "that target is not destroyed" + ); + ok( + targets[0].targetFront.isDestroyed(), + "but the previous one is destroyed" + ); + } + + await onNewTargetProcessed; + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testNestedIframes() { + info("Test TargetCommand against nested frames"); + + const nestedIframeUrl = `https://example.com/document-builder.sjs?html=${encodeURIComponent( + "<title>second</title><h3>second level iframe</h3>" + )}&delay=500`; + + const testUrl = `data:text/html;charset=utf-8, + <h1>Top-level</h1> + <iframe id=first-level + src='data:text/html;charset=utf-8,${encodeURIComponent( + `<title>first</title><h2>first level iframe</h2><iframe id=second-level src="${nestedIframeUrl}"></iframe>` + )}' + ></iframe>`; + + // Create a TargetCommand for a given test tab + const tab = await addTab(testUrl); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + + is(frames[0], targetCommand.targetFront, "First target is the top level one"); + const topParent = await frames[0].getParentTarget(); + is(topParent, null, "Top level target has no parent"); + + if (isEveryFrameTargetEnabled()) { + const firstIframeTarget = frames.find(target => target.title == "first"); + ok( + firstIframeTarget, + "With EFT, got the target for the first level iframe" + ); + const firstParent = await firstIframeTarget.getParentTarget(); + is( + firstParent, + targetCommand.targetFront, + "With EFT, first level has top level target as parent" + ); + + const secondIframeTarget = frames.find(target => target.title == "second"); + ok(secondIframeTarget, "Got the target for the second level iframe"); + const secondParent = await secondIframeTarget.getParentTarget(); + is( + secondParent, + firstIframeTarget, + "With EFT, second level has the first level target as parent" + ); + } else if (isFissionEnabled()) { + const secondIframeTarget = frames.find(target => target.title == "second"); + ok(secondIframeTarget, "Got the target for the second level iframe"); + const secondParent = await secondIframeTarget.getParentTarget(); + is( + secondParent, + targetCommand.targetFront, + "With fission, second level has top level target as parent" + ); + } + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js new file mode 100644 index 0000000000..68f7244671 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we create targets for popups + +const TEST_URL = "https://example.org/document-builder.sjs?html=main page"; +const POPUP_URL = "https://example.com/document-builder.sjs?html=popup"; +const POPUP_SECOND_URL = + "https://example.com/document-builder.sjs?html=popup-navigated"; + +add_task(async function () { + await pushPref("devtools.popups.debug", true); + // We expect to create a target for a same-process iframe + // in the test against window.open to load a document in an iframe. + await pushPref("devtools.every-frame-target.enabled", true); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const onAvailable = ({ targetFront }) => { + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + is(targets.length, 1, "At first, we only get one target"); + is( + targets[0], + targetCommand.targetFront, + "And this target is the top level one" + ); + + info("Open a popup"); + const firstPopupBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [POPUP_URL], + url => { + const win = content.open(url); + return win.browsingContext; + } + ); + + await waitFor(() => targets.length === 2); + ok(true, "We are notified about the first popup's target"); + + is( + targets[1].browsingContextID, + firstPopupBrowsingContext.id, + "the new target is for the popup" + ); + is(targets[1].url, POPUP_URL, "the new target has the right url"); + + info("Navigate the popup to a second location"); + await SpecialPowers.spawn( + firstPopupBrowsingContext, + [POPUP_SECOND_URL], + url => { + content.location.href = url; + } + ); + + await waitFor(() => targets.length === 3); + ok(true, "We are notified about the new location popup's target"); + + await waitFor(() => destroyedTargets.length === 1); + ok(true, "The first popup's target is destroyed"); + is( + destroyedTargets[0], + targets[1], + "The destroyed target is the popup's one" + ); + + is( + targets[2].browsingContextID, + firstPopupBrowsingContext.id, + "the new location target is for the popup" + ); + is( + targets[2].url, + POPUP_SECOND_URL, + "the new location target has the right url" + ); + + info("Close the popup"); + await SpecialPowers.spawn(firstPopupBrowsingContext, [], () => { + content.close(); + }); + + await waitFor(() => destroyedTargets.length === 2); + ok(true, "The popup's target is destroyed"); + is( + destroyedTargets[1], + targets[2], + "The destroyed target is the popup's one" + ); + + info("Open a about:blank popup"); + const aboutBlankPopupBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + const win = content.open("about:blank"); + return win.browsingContext; + } + ); + + await waitFor(() => targets.length === 4); + ok(true, "We are notified about the about:blank popup's target"); + + is( + targets[3].browsingContextID, + aboutBlankPopupBrowsingContext.id, + "the new target is for the popup" + ); + is(targets[3].url, "about:blank", "the new target has the right url"); + + info("Select the original tab and reload it"); + gBrowser.selectedTab = tab; + await BrowserTestUtils.reloadTab(tab); + + await waitFor(() => targets.length === 5); + is(targets[4], targetCommand.targetFront, "We get a new top level target"); + ok(!targets[3].isDestroyed(), "The about:blank popup target is still alive"); + + info("Call about:blank popup method to ensure it really is functional"); + await targets[3].logInPage("foo"); + + info( + "Ensure that iframe using window.open to load their document aren't considered as popups" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const iframe = content.document.createElement("iframe"); + iframe.setAttribute("name", "test-iframe"); + content.document.documentElement.appendChild(iframe); + content.open("data:text/html,iframe", "test-iframe"); + }); + await waitFor(() => targets.length === 6); + is( + targets[5].targetForm.isPopup, + false, + "The iframe target isn't considered as a popup" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js new file mode 100644 index 0000000000..d05ff5a962 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the framework handles reloading a document with multiple remote frames (See Bug 1724909). + +const REMOTE_ORIGIN = "https://example.com/"; +const REMOTE_IFRAME_URL_1 = + REMOTE_ORIGIN + "/document-builder.sjs?html=first_remote_iframe"; +const REMOTE_IFRAME_URL_2 = + REMOTE_ORIGIN + "/document-builder.sjs?html=second_remote_iframe"; +const TEST_URL = + "https://example.org/document-builder.sjs?html=org" + + `<iframe src=${REMOTE_IFRAME_URL_1}></iframe>` + + `<iframe src=${REMOTE_IFRAME_URL_2}></iframe>`; + +add_task(async function () { + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const onAvailable = ({ targetFront }) => { + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + await waitFor(() => targets.length === 3); + ok( + true, + "We are notified about the top-level document and the 2 remote iframes" + ); + + info("Reload the page"); + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = targetCommand.once("processed-available-target"); + gBrowser.reloadTab(tab); + await onNewTargetProcessed; + + await waitFor(() => targets.length === 6 && destroyedTargets.length === 3); + + // Get the previous targets in a dedicated array and remove them from `targets` + const previousTargets = targets.splice(0, 3); + ok( + previousTargets.every(targetFront => targetFront.isDestroyed()), + "The previous targets are all destroyed" + ); + ok( + targets.every(targetFront => !targetFront.isDestroyed()), + "The new targets are not destroyed" + ); + + info("Reload one of the iframe"); + SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const iframeEl = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframeEl.browsingContext, [], () => { + content.document.location.reload(); + }); + }); + await waitFor( + () => + targets.length + previousTargets.length === 7 && + destroyedTargets.length === 4 + ); + const iframeTarget = targets.find(t => t === destroyedTargets.at(-1)); + ok(iframeTarget, "Got the iframe target that got destroyed"); + for (const target of targets) { + if (target == iframeTarget) { + ok( + target.isDestroyed(), + "The iframe target we navigated from is destroyed" + ); + } else { + ok( + !target.isDestroyed(), + `Target ${target.actorID}|${target.url} isn't destroyed` + ); + } + } + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js new file mode 100644 index 0000000000..a7d5e51b3c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API getAllTargets. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +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); + + info("Setup the test page with workers of all types"); + + 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 commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + info("Check getAllTargets will throw when providing invalid arguments"); + Assert.throws( + () => targetCommand.getAllTargets(), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + Assert.throws( + () => targetCommand.getAllTargets([]), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + info("Check getAllTargets returns consistent results with several types"); + const workerTargets = targetCommand.getAllTargets([TYPES.WORKER]); + const serviceWorkerTargets = targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + const sharedWorkerTargets = targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const processTargets = targetCommand.getAllTargets([TYPES.PROCESS]); + const frameTargets = targetCommand.getAllTargets([TYPES.FRAME]); + + const allWorkerTargetsReference = [ + ...workerTargets, + ...serviceWorkerTargets, + ...sharedWorkerTargets, + ]; + const allWorkerTargets = targetCommand.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 = targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is( + allTargets.length, + allTargetsReference.length, + "getAllTargets(ALL_TYPES) returned the expected number of targets" + ); + + ok( + allTargets.every(t => allTargetsReference.includes(t)), + "getAllTargets(ALL_TYPES) returned the expected targets" + ); + + for (const target of allTargets) { + is( + target.commands, + commands, + "Each target front has a `commands` attribute - " + target + ); + } + + // Wait for all the targets to be fully attached so we don't have pending requests. + await waitForAllTargetsToBeAttached(targetCommand); + + ok( + !targetCommand.isDestroyed(), + "TargetCommand isn't destroyed before calling commands.destroy()" + ); + await commands.destroy(); + ok( + targetCommand.isDestroyed(), + "TargetCommand is destroyed after calling commands.destroy()" + ); + + 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/commands/target/tests/browser_target_command_invalid_api_usage.js b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js new file mode 100644 index 0000000000..dbdaae7f05 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test watch/unwatchTargets throw when provided with invalid types. + +const TEST_URL = "data:text/html;charset=utf-8,invalid api usage test"; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + const onAvailable = function () {}; + + await Assert.rejects( + targetCommand.watchTargets({ types: [null], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for null type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ types: [undefined], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for undefined type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ types: ["NOT_A_TARGET"], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for unknown type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ + types: [targetCommand.TYPES.FRAME, "NOT_A_TARGET"], + onAvailable, + }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for unknown type mixed with a correct type" + ); + + Assert.throws( + () => targetCommand.unwatchTargets({ types: [null], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for null type" + ); + + Assert.throws( + () => targetCommand.unwatchTargets({ types: [undefined], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for undefined type" + ); + + Assert.throws( + () => + targetCommand.unwatchTargets({ types: ["NOT_A_TARGET"], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for unknown type" + ); + + Assert.throws( + () => + targetCommand.unwatchTargets({ + types: [targetCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_TARGET"], + onAvailable, + }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for unknown type mixed with a correct type" + ); + + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_processes.js b/devtools/shared/commands/target/tests/browser_target_command_processes.js new file mode 100644 index 0000000000..d9e7ec65a9 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_processes.js @@ -0,0 +1,242 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around processes + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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 commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + await testProcesses(targetCommand, targetCommand.targetFront); + + targetCommand.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); + +add_task(async function () { + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const created = []; + const destroyed = []; + const onAvailable = ({ targetFront }) => { + created.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyed.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + ok(created.length > 1, "We get many content process targets"); + + targetCommand.stopListening(); + + await waitFor( + () => created.length == destroyed.length, + "Wait for the destruction of all content process targets when calling stopListening" + ); + is( + created.length, + destroyed.length, + "Got notification of destruction for all previously reported targets" + ); + + targetCommand.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); + +async function testProcesses(targetCommand, target) { + info("Test TargetCommand against processes"); + const { TYPES } = targetCommand; + + // 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 targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes.length, + originalProcessesCount, + "Get a target for all content processes" + ); + + const processes2 = await targetCommand.getAllTargets([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 pidRegExp = /^\d+$/; + + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + ok( + pidRegExp.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroy without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + 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 targetCommand.watchTargets({ + types: [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; + } + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: 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 onDestroy is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: 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" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + + // Ensure that getAllTargets still works after the call to unwatchTargets + const processes3 = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes3.length, + processCountAfterTabOpen - 1, + "getAllTargets reports a new target" + ); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_reload.js b/devtools/shared/commands/target/tests/browser_target_command_reload.js new file mode 100644 index 0000000000..9d8cacd23d --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_reload.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's reload method +// +// Note that we reload against main process, +// but this is hard/impossible to test as it reloads the test script itself +// and so stops its execution. + +// Load a page with a JS script that change its value everytime we load it +// (that's to see if the reload loads from cache or not) +const TEST_URL = URL_ROOT + "incremental-js-value-script.sjs"; + +add_task(async function () { + info(" ### Test reloading a Tab"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + const firstJSValue = await getContentVariable(); + is(firstJSValue, "1", "Got an initial value for the JS variable"); + + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await targetCommand.reloadTopLevelTarget(); + info("Wait for the tab to be reloaded"); + await onReloaded; + + const secondJSValue = await getContentVariable(); + is( + secondJSValue, + "1", + "The first reload didn't bypass the cache, so the JS Script is the same and we got the same value" + ); + + const onSecondReloaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + await targetCommand.reloadTopLevelTarget(true); + info("Wait for the tab to be reloaded"); + await onSecondReloaded; + + // The value is 3 and not 2, because we got a HTTP request, but it returned 304 and the browser fetched his cached content + const thirdJSValue = await getContentVariable(); + is( + thirdJSValue, + "3", + "The second reload did bypass the cache, so the JS Script is different and we got a new value" + ); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +}); + +add_task(async function () { + info(" ### Test reloading an Add-on"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background() { + const { browser } = this; + browser.test.log("background script executed"); + }, + }); + + await extension.startup(); + + const commands = await CommandsFactory.forAddon(extension.id); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + const { onResource: onReloaded } = + await commands.resourceCommand.waitForNextResource( + commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "dom-loading"; + }, + } + ); + + const backgroundPageURL = targetCommand.targetFront.url; + ok(backgroundPageURL, "Got the background page URL"); + await targetCommand.reloadTopLevelTarget(); + + info("Wait for next dom-loading DOCUMENT_EVENT"); + const event = await onReloaded; + + // If we get about:blank here, it most likely means we receive notification + // for the previous background page being unload and navigating to about:blank + is( + event.url, + backgroundPageURL, + "We received the DOCUMENT_EVENT's for the expected document: the new background page." + ); + + await commands.destroy(); + + await extension.unload(); +}); +function getContentVariable() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.jsValue; + }); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js new file mode 100644 index 0000000000..f07a6aaac3 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API with changes made to devtools.browsertoolbox.scope + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Do not run this test when both fission and EFT is disabled as it changes + // the number of targets + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + return; + } + + // 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); + + // First test with multiprocess debugging enabled + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + const { TYPES } = targetCommand; + + const targets = new Set(); + const destroyedTargetIsModeSwitchingMap = new Map(); + const onAvailable = async ({ targetFront }) => { + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront, isModeSwitching }) => { + destroyedTargetIsModeSwitchingMap.set(targetFront, isModeSwitching); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS, TYPES.FRAME], + onAvailable, + onDestroyed, + }); + ok(targets.size > 1, "We get many targets"); + + info("Open a tab in a new content process"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const newTabProcessID = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .osPid; + const newTabInnerWindowId = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .innerWindowId; + + info("Wait for the tab content process target"); + const processTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.PROCESS && + target.processID == newTabProcessID + ) + ); + + info("Wait for the tab window global target"); + const windowGlobalTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ) + ); + + let multiprocessTargetCount = targets.size; + + info("Disable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info("Wait for all targets but top level and workers to be destroyed"); + await waitFor(() => + [...targets].every( + target => + target == targetCommand.targetFront || target.targetType == TYPES.WORKER + ) + ); + + ok(processTarget.isDestroyed(), "The process target is destroyed"); + ok( + destroyedTargetIsModeSwitchingMap.get(processTarget), + "isModeSwitching was passed to onTargetDestroyed and is true for the process target" + ); + ok(windowGlobalTarget.isDestroyed(), "The window global target is destroyed"); + ok( + destroyedTargetIsModeSwitchingMap.get(windowGlobalTarget), + "isModeSwitching was passed to onTargetDestroyed and is true for the window global target" + ); + + info("Open a second tab in a new content process"); + const parentProcessTargetCount = targets.size; + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + await wait(1000); + is( + parentProcessTargetCount, + targets.size, + "The new tab process should be ignored and no target be created" + ); + + info("Re-enable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // The second tab relates to one content process target and one window global target + multiprocessTargetCount += 2; + + await waitFor( + () => targets.size == multiprocessTargetCount, + "Wait for all targets we used to have before disable multiprocess debugging" + ); + + info("Wait for the tab content process target to be available again"); + ok( + [...targets].some( + target => + target.targetType == TYPES.PROCESS && + target.processID == newTabProcessID + ), + "We have the tab content process target" + ); + + info("Wait for the tab window global target to be available again"); + ok( + [...targets].some( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ), + "We have the tab window global target" + ); + + info("Open a third tab in a new content process"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const thirdTabProcessID = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .osPid; + const thirdTabInnerWindowId = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .innerWindowId; + + info("Wait for the third tab content process target"); + await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.PROCESS && + target.processID == thirdTabProcessID + ) + ); + + info("Wait for the third tab window global target"); + await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == thirdTabInnerWindowId + ) + ); + + targetCommand.destroy(); + + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js new file mode 100644 index 0000000000..d71401fd8c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API for service workers in content tabs. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.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); + + info("Setup the test page with workers of all types"); + + const tab = await addTab(FISSION_TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + // Enable Service Worker listening. + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + const serviceWorkerTargets = targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is( + serviceWorkerTargets.length, + 1, + "TargetCommmand 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 targetCommand.watchTargets({ + types: [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 + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js new file mode 100644 index 0000000000..caf95f11c2 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js @@ -0,0 +1,389 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand 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 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() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({ + 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.loadURIString(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"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + 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 .com page"); + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, COM_PAGE_URL); + await onBrowserLoaded; + 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 + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + 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() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({ + 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.loadURIString(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.loadURIString(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 + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +/** + * In this test we load a service worker in a page prior to starting the + * TargetCommand. 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 TargetCommand. + * + * 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, +}) { + 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"); + let onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURIString(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"); + // wait for the browser to be loaded otherwise the task spawned in waitForRegistrationReady + // might be destroyed (when it still belongs to the previous content process) + await onBrowserLoaded; + await waitForRegistrationReady(tab, ORG_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({ + 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"); + onBrowserLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, COM_PAGE_URL); + await onBrowserLoaded; + + 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 + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +} + +async function setupServiceWorkerNavigationTest() { + // 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); +} + +async function watchServiceWorkerTargets({ + destroyServiceWorkersOnNavigation, + tab, +}) { + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Enable Service Worker listening. + targetCommand.listenForServiceWorkers = true; + info( + "Set targetCommand.destroyServiceWorkersOnNavigation to " + + destroyServiceWorkersOnNavigation + ); + targetCommand.destroyServiceWorkersOnNavigation = + destroyServiceWorkersOnNavigation; + await targetCommand.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 targetCommand.watchTargets({ + types: [targetCommand.TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed, + }); + + return { hooks, commands, targetCommand }; +} + +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/commands/target/tests/browser_target_command_switchToTarget.js b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js new file mode 100644 index 0000000000..04646117a9 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API switchToTarget function + +add_task(async function testSwitchToTarget() { + info("Test TargetCommand.switchToTarget method"); + + // 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 commands = await CommandsFactory.forTab(firstTab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // 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>` + ); + // We have to spawn a new distinct `commands` object for this new tab, + // but we will otherwise consider the first one as the main one. + // From this second one, we will only retrieve a new target. + const secondCommands = await CommandsFactory.forTab(secondTab, { + client: commands.client, + }); + await secondCommands.targetCommand.startListening(); + const secondTarget = secondCommands.targetCommand.targetFront; + + const frameTargets = []; + const firstTarget = targetCommand.targetFront; + let currentTarget = targetCommand.targetFront; + const onFrameAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + 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, + 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 targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable: onFrameAvailable, + onDestroyed: 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 targetCommand.switchToTarget(secondTarget); + + is( + targetCommand.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" + ); + } + + targetCommand.destroy(); + + await commands.destroy(); + await secondCommands.destroy(); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js new file mode 100644 index 0000000000..24710879ae --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js @@ -0,0 +1,322 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around workers + +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 REMOTE_IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + 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); + + // 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 TargetCommand against workers via a tab target"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetCommand for the tab + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetCommand.listenForWorkers = true; + + await commands.targetCommand.startListening(); + + const { TYPES } = targetCommand; + + info("Check that getAllTargets only returns dedicated workers"); + const workers = await targetCommand.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 => { + return worker.url == `${REMOTE_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 onDestroyed = 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 targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + + // 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 waitFor( + () => targets.length === 4, + "Wait for the target list to notify us about the spawned worker" + ); + const mainPageSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url == `${WORKER_URL}#spawned-worker` + ); + ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker"); + const iframeSpawnedWorkerTarget = targets.find( + innerTarget => + innerTarget.url == `${REMOTE_IFRAME_WORKER_URL}#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 waitFor( + () => + 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; + await reloadBrowser(); + + await waitFor(() => { + 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 waitFor( + () => 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 == `${REMOTE_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 waitFor(() => { + 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 waitFor( + () => 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_URL}#simple-worker-in-created-iframe-1` + ); + const secondSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-2` + ); + const firstSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == + `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-1` + ); + const secondSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == + `${REMOTE_IFRAME_WORKER_URL}#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.loadURIString( + tab.linkedBrowser, + "data:text/html,<meta charset=utf8>Away" + ); + + await waitFor( + () => destroyedTargets.length === targets.length, + "Wait for all the targets to be reported 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" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(commands.client); + + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js new file mode 100644 index 0000000000..0f1ea45a08 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test WORKER targets when doing history navigations (BF Cache) +// +// Use a distinct file as this test currently hits a DEBUG assertion +// https://searchfox.org/mozilla-central/rev/352b525ab841278cd9b3098343f655ef85933544/dom/workers/WorkerPrivate.cpp#5218 +// and so is running only on OPT builds. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + 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); + + // 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 bfcache navigations"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetCommand for the tab + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetCommand.listenForWorkers = true; + + await targetCommand.startListening(); + + const { TYPES } = targetCommand; + + info( + "Assert that watchTargets will call the onAvailable 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} new targets`); + }; + const onDestroyed = 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 targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + + is(targets.length, 2, "watchTargets retrieved 2 workers…"); + const mainPageWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTarget = targets.find( + worker => worker.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + 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("Check that navigating away does destroy all targets"); + const onBrowserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "data:text/html,<meta charset=utf8>Away" + ); + await onBrowserLoaded; + + await waitFor( + () => destroyedTargets.length === 2, + "Wait for all the targets to be reported as destroyed" + ); + + info("Navigate back to the first page"); + gBrowser.goBack(); + + await waitFor( + () => targets.length === 4, + "Wait for the target list to notify us about the first page workers, restored from the BF Cache" + ); + + const mainPageWorkerTargetAfterGoingBack = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterGoingBack = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterGoingBack, + "The target list handled the worker created from the BF Cache" + ); + ok( + iframeWorkerTargetAfterGoingBack, + "The target list handled the worker created in the iframe from the BF Cache" + ); + + targetCommand.destroy(); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js new file mode 100644 index 0000000000..b61faf8f6e --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js @@ -0,0 +1,283 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API with all possible descriptors + +const TEST_URL = "https://example.org/document-builder.sjs?html=org"; +const SECOND_TEST_URL = "https://example.com/document-builder.sjs?html=org"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +add_task(async function () { + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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); + + await testLocalTab(); + await testRemoteTab(); + await testParentProcess(); + await testWorker(); + await testWebExtension(); +}); + +async function testParentProcess() { + info("Test TargetCommand against parent process descriptor"); + + const commands = await CommandsFactory.forMainProcess(); + const { descriptorFront } = commands; + + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.PROCESS, + "The descriptor type is correct" + ); + is( + descriptorFront.isParentProcessDescriptor, + true, + "Descriptor front isParentProcessDescriptor is correct" + ); + is( + descriptorFront.isProcessDescriptor, + true, + "Descriptor front isProcessDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + ok( + targets.length > 1, + "We get many targets when debugging the parent process" + ); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the parent process target is of frame type, because it inherits from WindowGlobalTargetActor" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + await waitForAllTargetsToBeAttached(targetCommand); + + await commands.destroy(); +} + +async function testLocalTab() { + info("Test TargetCommand against local tab descriptor (via getTab({ tab }))"); + + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.TAB, + "The descriptor type is correct" + ); + is( + descriptorFront.isTabDescriptor, + true, + "Descriptor front isTabDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the tab target is of frame type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testRemoteTab() { + info( + "Test TargetCommand against remote tab descriptor (via getTab({ browserId }))" + ); + + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId + ); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.TAB, + "The descriptor type is correct" + ); + is( + descriptorFront.isTabDescriptor, + true, + "Descriptor front isTabDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is( + targetFront, + targetCommand.targetFront, + "TargetCommand top target is the same as the first target" + ); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the tab target is of frame type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.loadURIString(browser, SECOND_TEST_URL); + await onLoaded; + + info("Wait for the new target"); + await waitFor(() => targetCommand.targetFront != targetFront); + isnot( + targetCommand.targetFront, + targetFront, + "The top level target changes on navigation" + ); + ok( + !targetCommand.targetFront.isDestroyed(), + "The new target isn't destroyed" + ); + ok(targetFront.isDestroyed(), "While the previous target is destroyed"); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testWebExtension() { + info("Test TargetCommand against webextension descriptor"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + name: "Sample extension", + }, + }); + + await extension.startup(); + + const commands = await CommandsFactory.forAddon(extension.id); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.EXTENSION, + "The descriptor type is correct" + ); + is( + descriptorFront.isWebExtensionDescriptor, + true, + "Descriptor front isWebExtensionDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the web extension target is of frame type, because it inherits from WindowGlobalTargetActor" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + await extension.unload(); + + await commands.destroy(); +} + +// CommandsFactory expect the worker id, which is computed from the nsIWorkerDebugger.id attribute +function getNextWorkerDebuggerId() { + return new Promise(resolve => { + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].createInstance(Ci.nsIWorkerDebuggerManager); + const listener = { + onRegister(dbg) { + wdm.removeListener(listener); + resolve(dbg.id); + }, + }; + wdm.addListener(listener); + }); +} +async function testWorker() { + info("Test TargetCommand against worker descriptor"); + + const workerUrl = CHROME_WORKER_URL + "#descriptor"; + const onNextWorker = getNextWorkerDebuggerId(); + const worker = new Worker(workerUrl); + const workerId = await onNextWorker; + ok(workerId, "Found the worker Debugger ID"); + + const commands = await CommandsFactory.forWorker(workerId); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.WORKER, + "The descriptor type is correct" + ); + is( + descriptorFront.isWorkerDescriptor, + true, + "Descriptor front isWorkerDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.WORKER, + "the worker target is of worker type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + // Calling CommandsFactory.forWorker, will call RootFront.getWorker + // which will spawn lots of worker legacy code, firing lots of requests, + // which may still be pending + await commands.waitForRequestsToSettle(); + + await commands.destroy(); + worker.terminate(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js new file mode 100644 index 0000000000..516780be01 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's `watchTargets` function + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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); + + await testWatchTargets(); + await testThrowingInOnAvailable(); +}); + +async function testWatchTargets() { + info("Test TargetCommand watchTargets function"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.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 topLevelTarget = targetCommand.targetFront; + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == topLevelTarget + ? 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, + 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 targetCommand.watchTargets({ + types: [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; + } + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: 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); + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: 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" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + + targetCommand.destroy(); + + await commands.destroy(); +} + +async function testThrowingInOnAvailable() { + info( + "Test TargetCommand watchTargets function when an exception is thrown in onAvailable callback" + ); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.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 targetCommand.watchTargets({ types: [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." + ); + + targetCommand.destroy(); + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js new file mode 100644 index 0000000000..6dd99d243b --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that watcher front/actor APIs do not lead to create duplicate actors. + +const TEST_URL = "data:text/html;charset=utf-8,Actor caching test"; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const { watcherFront } = targetCommand; + ok(watcherFront, "A watcherFront is available on targetCommand"); + + info("Check that getNetworkParentActor does not create duplicate actors"); + testActorGetter( + watcherFront, + () => watcherFront.getNetworkParentActor(), + "networkParent" + ); + + info("Check that getBreakpointListActor does not create duplicate actors"); + testActorGetter( + watcherFront, + () => watcherFront.getBreakpointListActor(), + "breakpoint-list" + ); + + info( + "Check that getTargetConfigurationActor does not create duplicate actors" + ); + testActorGetter( + watcherFront, + () => watcherFront.getTargetConfigurationActor(), + "target-configuration" + ); + + info( + "Check that getThreadConfigurationActor does not create duplicate actors" + ); + testActorGetter( + watcherFront, + () => watcherFront.getThreadConfigurationActor(), + "thread-configuration" + ); + + targetCommand.destroy(); + await commands.waitForRequestsToSettle(); + await commands.destroy(); +}); + +/** + * Check that calling an actor getter method on the watcher front leads to the + * creation of at most 1 actor. + */ +async function testActorGetter(watcherFront, actorGetterFn, typeName) { + checkPoolChildrenSize(watcherFront, typeName, 0); + + const actor = await actorGetterFn(); + checkPoolChildrenSize(watcherFront, typeName, 1); + + const otherActor = await actorGetterFn(); + is(actor, otherActor, "Returned the same actor for " + typeName); + + checkPoolChildrenSize(watcherFront, typeName, 1); +} + +/** + * Assert that a given parent pool has the expected number of children for + * a given typeName. + */ +function checkPoolChildrenSize(parentPool, typeName, expected) { + const children = [...parentPool.poolChildren()]; + const childrenByType = children.filter(pool => pool.typeName === typeName); + is( + childrenByType.length, + expected, + `${parentPool.actorID} should have ${expected} children of type ${typeName}` + ); +} diff --git a/devtools/shared/commands/target/tests/fission_document.html b/devtools/shared/commands/target/tests/fission_document.html new file mode 100644 index 0000000000..62afe347e3 --- /dev/null +++ b/devtools/shared/commands/target/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/commands/target/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/target/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/commands/target/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/commands/target/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/commands/target/tests/fission_iframe.html b/devtools/shared/commands/target/tests/fission_iframe.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/commands/target/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/commands/target/tests/head.js b/devtools/shared/commands/target/tests/head.js new file mode 100644 index 0000000000..ecb3fc1828 --- /dev/null +++ b/devtools/shared/commands/target/tests/head.js @@ -0,0 +1,32 @@ +/* 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"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +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; +} diff --git a/devtools/shared/commands/target/tests/incremental-js-value-script.sjs b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs new file mode 100644 index 0000000000..a612a3cb59 --- /dev/null +++ b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs @@ -0,0 +1,23 @@ +"use strict"; + +function handleRequest(request, response) { + const Etag = '"4d881ab-b03-435f0a0f9ef00"'; + const IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + const counter = getState("cache-counter") || 1; + const page = "<script>var jsValue = '" + counter + "';</script>" + counter; + + setState("cache-counter", "" + (parseInt(counter, 10) + 1)); + + response.setHeader("Etag", Etag, false); + + if (IfNoneMatch === Etag) { + response.setStatusLine(request.httpVersion, "304", "Not Modified"); + } else { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); + } +} diff --git a/devtools/shared/commands/target/tests/simple_document.html b/devtools/shared/commands/target/tests/simple_document.html new file mode 100644 index 0000000000..d6a449e489 --- /dev/null +++ b/devtools/shared/commands/target/tests/simple_document.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test empty document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test empty document</p> +</body> +</html> diff --git a/devtools/shared/commands/target/tests/test_service_worker.js b/devtools/shared/commands/target/tests/test_service_worker.js new file mode 100644 index 0000000000..aabc3fda0f --- /dev/null +++ b/devtools/shared/commands/target/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/commands/target/tests/test_sw_page.html b/devtools/shared/commands/target/tests/test_sw_page.html new file mode 100644 index 0000000000..38aad04259 --- /dev/null +++ b/devtools/shared/commands/target/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/commands/target/tests/test_sw_page_worker.js b/devtools/shared/commands/target/tests/test_sw_page_worker.js new file mode 100644 index 0000000000..29cda68560 --- /dev/null +++ b/devtools/shared/commands/target/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/commands/target/tests/test_worker.js b/devtools/shared/commands/target/tests/test_worker.js new file mode 100644 index 0000000000..ce3dd39cea --- /dev/null +++ b/devtools/shared/commands/target/tests/test_worker.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +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); + } +}; |