diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/remotepagemanager | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/remotepagemanager')
8 files changed, 1397 insertions, 0 deletions
diff --git a/toolkit/components/remotepagemanager/MessagePort.jsm b/toolkit/components/remotepagemanager/MessagePort.jsm new file mode 100644 index 0000000000..963d821a76 --- /dev/null +++ b/toolkit/components/remotepagemanager/MessagePort.jsm @@ -0,0 +1,280 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["MessagePort", "MessageListener"]; + +ChromeUtils.defineModuleGetter( + this, + "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm" +); + +class MessageListener { + constructor() { + this.listeners = new Map(); + } + + keys() { + return this.listeners.keys(); + } + + has(name) { + return this.listeners.has(name); + } + + callListeners(message) { + let listeners = this.listeners.get(message.name); + if (!listeners) { + return; + } + + for (let listener of listeners.values()) { + try { + listener(message); + } catch (e) { + Cu.reportError(e); + } + } + } + + addMessageListener(name, callback) { + if (!this.listeners.has(name)) { + this.listeners.set(name, new Set([callback])); + } else { + this.listeners.get(name).add(callback); + } + } + + removeMessageListener(name, callback) { + if (!this.listeners.has(name)) { + return; + } + + this.listeners.get(name).delete(callback); + } +} + +/* + * A message port sits on each side of the process boundary for every remote + * page. Each has a port ID that is unique to the message manager it talks + * through. + * + * We roughly implement the same contract as nsIMessageSender and + * nsIMessageListenerManager + */ +class MessagePort { + constructor(messageManagerOrActor, portID) { + this.messageManager = messageManagerOrActor; + this.portID = portID; + this.destroyed = false; + this.listener = new MessageListener(); + + // This is a sparse array of pending requests. The id of each request is + // simply its index in the array. The next id is the current length of the + // array (which includes the count of missing indexes). + this.requests = []; + + this.message = this.message.bind(this); + this.receiveRequest = this.receiveRequest.bind(this); + this.receiveResponse = this.receiveResponse.bind(this); + this.addMessageListeners(); + } + + addMessageListeners() { + if (!(this.messageManager instanceof Ci.nsIMessageSender)) { + return; + } + + this.messageManager.addMessageListener("RemotePage:Message", this.message); + this.messageManager.addMessageListener( + "RemotePage:Request", + this.receiveRequest + ); + this.messageManager.addMessageListener( + "RemotePage:Response", + this.receiveResponse + ); + } + + removeMessageListeners() { + if (!(this.messageManager instanceof Ci.nsIMessageSender)) { + return; + } + + this.messageManager.removeMessageListener( + "RemotePage:Message", + this.message + ); + this.messageManager.removeMessageListener( + "RemotePage:Request", + this.receiveRequest + ); + this.messageManager.removeMessageListener( + "RemotePage:Response", + this.receiveResponse + ); + } + + // Called when the message manager used to connect to the other process has + // changed, i.e. when a tab is detached. + swapMessageManager(messageManager) { + this.removeMessageListeners(); + this.messageManager = messageManager; + this.addMessageListeners(); + } + + // Sends a request to the other process and returns a promise that completes + // once the other process has responded to the request or some error occurs. + sendRequest(name, data = null) { + if (this.destroyed) { + return this.window.Promise.reject( + new Error("Message port has been destroyed") + ); + } + + let deferred = PromiseUtils.defer(); + this.requests.push(deferred); + + this.messageManager.sendAsyncMessage("RemotePage:Request", { + portID: this.portID, + requestID: this.requests.length - 1, + name, + data, + }); + + return this.wrapPromise(deferred.promise); + } + + // Handles an IPC message to perform a request of some kind. + async receiveRequest({ data: messagedata }) { + if (this.destroyed || messagedata.portID != this.portID) { + return; + } + + let data = { + portID: this.portID, + requestID: messagedata.requestID, + }; + + try { + data.resolve = await this.handleRequest( + messagedata.name, + messagedata.data + ); + } catch (e) { + data.reject = e; + } + + this.messageManager.sendAsyncMessage("RemotePage:Response", data); + } + + // Handles an IPC message with the response of a request. + receiveResponse({ data: messagedata }) { + if (this.destroyed || messagedata.portID != this.portID) { + return; + } + + let deferred = this.requests[messagedata.requestID]; + if (!deferred) { + Cu.reportError("Received a response to an unknown request."); + return; + } + + delete this.requests[messagedata.requestID]; + + if ("resolve" in messagedata) { + deferred.resolve(messagedata.resolve); + } else if ("reject" in messagedata) { + deferred.reject(messagedata.reject); + } else { + deferred.reject(new Error("Internal RPM error.")); + } + } + + // Handles an IPC message containing any message. + message({ data: messagedata }) { + if (this.destroyed || messagedata.portID != this.portID) { + return; + } + + this.handleMessage(messagedata); + } + + /* Adds a listener for messages. Many callbacks can be registered for the + * same message if necessary. An attempt to register the same callback for the + * same message twice will be ignored. When called the callback is passed an + * object with these properties: + * target: This message port + * name: The message name + * data: Any data sent with the message + */ + addMessageListener(name, callback) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.listener.addMessageListener(name, callback); + } + + /* + * Removes a listener for messages. + */ + removeMessageListener(name, callback) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.listener.removeMessageListener(name, callback); + } + + // Sends a message asynchronously to the other process + sendAsyncMessage(name, data = null) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + let id; + if (this.window) { + id = this.window.docShell.browsingContext.id; + } + if (this.messageManager instanceof Ci.nsIMessageSender) { + this.messageManager.sendAsyncMessage("RemotePage:Message", { + portID: this.portID, + browsingContextID: id, + name, + data, + }); + } else { + this.messageManager.sendAsyncMessage(name, data); + } + } + + // Called to destroy this port + destroy() { + try { + // This can fail in the child process if the tab has already been closed + this.removeMessageListeners(); + } catch (e) {} + + for (let deferred of this.requests) { + if (deferred) { + deferred.reject(new Error("Message port has been destroyed")); + } + } + + this.messageManager = null; + this.destroyed = true; + this.portID = null; + this.listener = null; + this.requests = []; + } + + wrapPromise(promise) { + return new this.window.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } +} diff --git a/toolkit/components/remotepagemanager/RemotePageManagerChild.jsm b/toolkit/components/remotepagemanager/RemotePageManagerChild.jsm new file mode 100644 index 0000000000..ea798a4b63 --- /dev/null +++ b/toolkit/components/remotepagemanager/RemotePageManagerChild.jsm @@ -0,0 +1,87 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["ChildMessagePort"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { MessagePort } = ChromeUtils.import( + "resource://gre/modules/remotepagemanager/MessagePort.jsm" +); + +// The content side of a message port +class ChildMessagePort extends MessagePort { + constructor(window) { + let portID = + Services.appinfo.processID + ":" + ChildMessagePort.nextPortID++; + super(window.docShell.messageManager, portID); + + this.window = window; + + // Add functionality to the content page + Cu.exportFunction(this.sendAsyncMessage.bind(this), window, { + defineAs: "RPMSendAsyncMessage", + }); + Cu.exportFunction(this.addMessageListener.bind(this), window, { + defineAs: "RPMAddMessageListener", + allowCallbacks: true, + }); + Cu.exportFunction(this.removeMessageListener.bind(this), window, { + defineAs: "RPMRemoveMessageListener", + allowCallbacks: true, + }); + + // The actor form only needs the functions set up above. The actor + // will send and receive messages directly. + if (!(this.messageManager instanceof Ci.nsIMessageSender)) { + return; + } + + // Send a message for load events + let loadListener = () => { + this.sendAsyncMessage("RemotePage:Load"); + window.removeEventListener("load", loadListener); + }; + window.addEventListener("load", loadListener); + + // Destroy the port when the window is unloaded + window.addEventListener("unload", () => { + try { + this.sendAsyncMessage("RemotePage:Unload"); + } catch (e) { + // If the tab has been closed the frame message manager has already been + // destroyed + } + this.destroy(); + }); + + // Tell the main process to set up its side of the message pipe. + this.messageManager.sendAsyncMessage("RemotePage:InitPort", { + portID, + url: window.document.documentURI.replace(/[\#|\?].*$/, ""), + }); + } + + // Called when the content process is requesting some data. + async handleRequest(name, data) { + throw new Error(`Unknown request ${name}.`); + } + + // Called when a message is received from the message manager or actor. + handleMessage(messagedata) { + let message = { + name: messagedata.name, + data: messagedata.data, + }; + this.listener.callListeners(Cu.cloneInto(message, this.window)); + } + + destroy() { + this.window = null; + super.destroy.call(this); + } +} + +ChildMessagePort.nextPortID = 0; diff --git a/toolkit/components/remotepagemanager/RemotePageManagerParent.jsm b/toolkit/components/remotepagemanager/RemotePageManagerParent.jsm new file mode 100644 index 0000000000..4d19483c7e --- /dev/null +++ b/toolkit/components/remotepagemanager/RemotePageManagerParent.jsm @@ -0,0 +1,351 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["RemotePages", "RemotePageManager"]; + +/* + * Using the RemotePageManager: + * * Create a new page listener by calling 'new RemotePages(URI)' which + * then injects functions like RPMGetBoolPref() into the registered page. + * One can then use those exported functions to communicate between + * child and parent. + * + * * When adding a new consumer of RPM that relies on other functionality + * then simple message passing provided by the RPM, then one has to + * whitelist permissions for the new URI within the RPMAccessManager + * from MessagePort.jsm. + */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { MessageListener, MessagePort } = ChromeUtils.import( + "resource://gre/modules/remotepagemanager/MessagePort.jsm" +); + +/** + * Creates a RemotePages object which listens for new remote pages of some + * particular URLs. A "RemotePage:Init" message will be dispatched to this + * object for every page loaded. Message listeners added to this object receive + * messages from all loaded pages from the requested urls. + */ +class RemotePages { + constructor(urls) { + this.urls = Array.isArray(urls) ? urls : [urls]; + this.messagePorts = new Set(); + this.listener = new MessageListener(); + this.destroyed = false; + + this.portCreated = this.portCreated.bind(this); + this.portMessageReceived = this.portMessageReceived.bind(this); + + for (const url of this.urls) { + RemotePageManager.addRemotePageListener(url, this.portCreated); + } + } + + destroy() { + for (const url of this.urls) { + RemotePageManager.removeRemotePageListener(url); + } + + for (let port of this.messagePorts.values()) { + this.removeMessagePort(port); + } + + this.messagePorts = null; + this.listener = null; + this.destroyed = true; + } + + // Called when a page matching one of the urls has loaded in a frame. + portCreated(port) { + this.messagePorts.add(port); + + port.loaded = false; + port.addMessageListener("RemotePage:Load", this.portMessageReceived); + port.addMessageListener("RemotePage:Unload", this.portMessageReceived); + + for (let name of this.listener.keys()) { + this.registerPortListener(port, name); + } + + this.listener.callListeners({ target: port, name: "RemotePage:Init" }); + } + + // A message has been received from one of the pages + portMessageReceived(message) { + switch (message.name) { + case "RemotePage:Load": + message.target.loaded = true; + break; + case "RemotePage:Unload": + message.target.loaded = false; + this.removeMessagePort(message.target); + break; + } + + this.listener.callListeners(message); + } + + // A page has closed + removeMessagePort(port) { + for (let name of this.listener.keys()) { + port.removeMessageListener(name, this.portMessageReceived); + } + + port.removeMessageListener("RemotePage:Load", this.portMessageReceived); + port.removeMessageListener("RemotePage:Unload", this.portMessageReceived); + this.messagePorts.delete(port); + } + + registerPortListener(port, name) { + port.addMessageListener(name, this.portMessageReceived); + } + + // Sends a message to all known pages + sendAsyncMessage(name, data = null) { + for (let port of this.messagePorts.values()) { + try { + port.sendAsyncMessage(name, data); + } catch (e) { + // Unless the port is in the process of unloading, something strange + // happened but allow other ports to receive the message + if (e.result !== Cr.NS_ERROR_NOT_INITIALIZED) { + Cu.reportError(e); + } + } + } + } + + addMessageListener(name, callback) { + if (this.destroyed) { + throw new Error("RemotePages has been destroyed"); + } + + if (!this.listener.has(name)) { + for (let port of this.messagePorts.values()) { + this.registerPortListener(port, name); + } + } + + this.listener.addMessageListener(name, callback); + } + + removeMessageListener(name, callback) { + if (this.destroyed) { + throw new Error("RemotePages has been destroyed"); + } + + this.listener.removeMessageListener(name, callback); + } + + portsForBrowser(browser) { + return [...this.messagePorts].filter(port => port.browser == browser); + } +} + +// Only exposes the public properties of the MessagePort +function publicMessagePort(port) { + let properties = [ + "addMessageListener", + "removeMessageListener", + "sendAsyncMessage", + "destroy", + ]; + + let clean = {}; + for (let property of properties) { + clean[property] = port[property].bind(port); + } + + Object.defineProperty(clean, "portID", { + enumerable: true, + get() { + return port.portID; + }, + }); + + if (port instanceof ChromeMessagePort) { + Object.defineProperty(clean, "browser", { + enumerable: true, + get() { + return port.browser; + }, + }); + + Object.defineProperty(clean, "url", { + enumerable: true, + get() { + return port.url; + }, + }); + } + + return clean; +} + +// The chome side of a message port +class ChromeMessagePort extends MessagePort { + constructor(browser, portID, url) { + super(browser.messageManager, portID); + + this._browser = browser; + this._permanentKey = browser.permanentKey; + this._url = url; + + Services.obs.addObserver(this, "message-manager-disconnect"); + this.publicPort = publicMessagePort(this); + + this.swapBrowsers = this.swapBrowsers.bind(this); + this._browser.addEventListener("SwapDocShells", this.swapBrowsers); + } + + get browser() { + return this._browser; + } + + get url() { + return this._url; + } + + // Called when the docshell is being swapped with another browser. We have to + // update to use the new browser's message manager + swapBrowsers({ detail: newBrowser }) { + // We can see this event for the new browser before the swap completes so + // check that the browser we're tracking has our permanentKey. + if (this._browser.permanentKey != this._permanentKey) { + return; + } + + this._browser.removeEventListener("SwapDocShells", this.swapBrowsers); + + this._browser = newBrowser; + this.swapMessageManager(newBrowser.messageManager); + + this._browser.addEventListener("SwapDocShells", this.swapBrowsers); + } + + // Called when a message manager has been disconnected indicating that the + // tab has closed or crashed + observe(messageManager) { + if (messageManager != this.messageManager) { + return; + } + + this.listener.callListeners({ + target: this.publicPort, + name: "RemotePage:Unload", + data: null, + }); + this.destroy(); + } + + // Called when the content process is requesting some data. + async handleRequest(name, data) { + throw new Error(`Unknown request ${name}.`); + } + + // Called when a message is received from the message manager. + handleMessage(messagedata) { + let message = { + target: this.publicPort, + name: messagedata.name, + data: messagedata.data, + browsingContextID: messagedata.browsingContextID, + }; + this.listener.callListeners(message); + + if (messagedata.name == "RemotePage:Unload") { + this.destroy(); + } + } + + destroy() { + try { + this._browser.removeEventListener("SwapDocShells", this.swapBrowsers); + } catch (e) { + // It's possible the browser instance is already dead so we can just ignore + // this error. + } + + this._browser = null; + Services.obs.removeObserver(this, "message-manager-disconnect"); + super.destroy.call(this); + } +} + +// Allows callers to register to connect to specific content pages. Registration +// is done through the addRemotePageListener method +var RemotePageManagerInternal = { + // The currently registered remote pages + pages: new Map(), + + // Initialises all the needed listeners + init() { + Services.mm.addMessageListener( + "RemotePage:InitPort", + this.initPort.bind(this) + ); + this.updateProcessUrls(); + }, + + updateProcessUrls() { + Services.ppmm.sharedData.set( + "RemotePageManager:urls", + new Set(this.pages.keys()) + ); + Services.ppmm.sharedData.flush(); + }, + + // Registers interest in a remote page. A callback is called with a port for + // the new page when loading begins (i.e. the page hasn't actually loaded yet). + // Only one callback can be registered per URL. + addRemotePageListener(url, callback) { + if (this.pages.has(url)) { + throw new Error("Remote page already registered: " + url); + } + + this.pages.set(url, callback); + this.updateProcessUrls(); + }, + + // Removes any interest in a remote page. + removeRemotePageListener(url) { + if (!this.pages.has(url)) { + throw new Error("Remote page is not registered: " + url); + } + + this.pages.delete(url); + this.updateProcessUrls(); + }, + + // A remote page has been created and a port is ready in the content side + initPort({ target: browser, data: { url, portID } }) { + let callback = this.pages.get(url); + if (!callback) { + Cu.reportError("Unexpected remote page load: " + url); + return; + } + + let port = new ChromeMessagePort(browser, portID, url); + callback(port.publicPort); + }, +}; + +if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw new Error("RemotePageManager can only be used in the main process."); +} + +RemotePageManagerInternal.init(); + +// The public API for the above object +var RemotePageManager = { + addRemotePageListener: RemotePageManagerInternal.addRemotePageListener.bind( + RemotePageManagerInternal + ), + removeRemotePageListener: RemotePageManagerInternal.removeRemotePageListener.bind( + RemotePageManagerInternal + ), +}; diff --git a/toolkit/components/remotepagemanager/moz.build b/toolkit/components/remotepagemanager/moz.build new file mode 100644 index 0000000000..85eebd78a6 --- /dev/null +++ b/toolkit/components/remotepagemanager/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "General") + +EXTRA_JS_MODULES.remotepagemanager = [ + "MessagePort.jsm", + "RemotePageManagerChild.jsm", + "RemotePageManagerParent.jsm", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] diff --git a/toolkit/components/remotepagemanager/tests/browser/browser.ini b/toolkit/components/remotepagemanager/tests/browser/browser.ini new file mode 100644 index 0000000000..e54e70c01a --- /dev/null +++ b/toolkit/components/remotepagemanager/tests/browser/browser.ini @@ -0,0 +1,6 @@ +[DEFAULT] +support-files = + testremotepagemanager.html + testremotepagemanager2.html + +[browser_RemotePageManager.js] diff --git a/toolkit/components/remotepagemanager/tests/browser/browser_RemotePageManager.js b/toolkit/components/remotepagemanager/tests/browser/browser_RemotePageManager.js new file mode 100644 index 0000000000..ac0f3cf83d --- /dev/null +++ b/toolkit/components/remotepagemanager/tests/browser/browser_RemotePageManager.js @@ -0,0 +1,570 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const TEST_URL = + "http://www.example.com/browser/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager.html"; + +var { RemotePages, RemotePageManager } = ChromeUtils.import( + "resource://gre/modules/remotepagemanager/RemotePageManagerParent.jsm" +); + +function failOnMessage(message) { + ok(false, "Should not have seen message " + message.name); +} + +function waitForMessage(port, message, expectedPort = port) { + return new Promise(resolve => { + function listener(message) { + is( + message.target, + expectedPort, + "Message should be from the right port." + ); + + port.removeMessageListener(listener); + resolve(message); + } + + port.addMessageListener(message, listener); + }); +} + +function waitForPort(url, createTab = true) { + return new Promise(resolve => { + RemotePageManager.addRemotePageListener(url, port => { + RemotePageManager.removeRemotePageListener(url); + + waitForMessage(port, "RemotePage:Load").then(() => resolve(port)); + }); + + if (createTab) { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url); + } + }); +} + +function waitForPage(pages, url = TEST_URL) { + return new Promise(resolve => { + function listener({ target }) { + pages.removeMessageListener("RemotePage:Init", listener); + + waitForMessage(target, "RemotePage:Load").then(() => resolve(target)); + } + + pages.addMessageListener("RemotePage:Init", listener); + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url); + }); +} + +function swapDocShells(browser1, browser2) { + // Swap frameLoaders. + browser1.swapDocShells(browser2); + + // Swap permanentKeys. + let tmp = browser1.permanentKey; + browser1.permanentKey = browser2.permanentKey; + browser2.permanentKey = tmp; +} + +add_task(async function sharedData_aka_initialProcessData() { + const includesTest = () => + Services.cpmm.sharedData.get("RemotePageManager:urls").has(TEST_URL); + is( + includesTest(), + false, + "Shouldn't have test url in initial process data yet" + ); + + const loadedPort = waitForPort(TEST_URL); + is(includesTest(), true, "Should have test url when waiting for it to load"); + + await loadedPort; + is(includesTest(), false, "Should have test url removed when done listening"); + + gBrowser.removeCurrentTab(); +}); + +// Test that opening a page creates a port, sends the load event and then +// navigating to a new page sends the unload event. Going back should create a +// new port +add_task(async function init_navigate() { + let port = await waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.loadURI(gBrowser, "about:blank"); + + await waitForMessage(port, "RemotePage:Unload"); + + // Port should be destroyed now + try { + port.addMessageListener("Foo", failOnMessage); + ok(false, "Should have seen exception"); + } catch (e) { + ok(true, "Should have seen exception"); + } + + try { + port.sendAsyncMessage("Foo"); + ok(false, "Should have seen exception"); + } catch (e) { + ok(true, "Should have seen exception"); + } + + await loaded; + + gBrowser.goBack(); + port = await waitForPort(TEST_URL, false); + + port.sendAsyncMessage("Ping2"); + await waitForMessage(port, "Pong2"); + port.destroy(); + + gBrowser.removeCurrentTab(); +}); + +// Test that opening a page creates a port, sends the load event and then +// closing the tab sends the unload event +add_task(async function init_close() { + let port = await waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let unloadPromise = waitForMessage(port, "RemotePage:Unload"); + gBrowser.removeCurrentTab(); + await unloadPromise; + + // Port should be destroyed now + try { + port.addMessageListener("Foo", failOnMessage); + ok(false, "Should have seen exception"); + } catch (e) { + ok(true, "Should have seen exception"); + } + + try { + port.sendAsyncMessage("Foo"); + ok(false, "Should have seen exception"); + } catch (e) { + ok(true, "Should have seen exception"); + } +}); + +// Tests that we can send messages to individual pages even when more than one +// is open +add_task(async function multiple_ports() { + let port1 = await waitForPort(TEST_URL); + is( + port1.browser, + gBrowser.selectedBrowser, + "Port is for the correct browser" + ); + + let port2 = await waitForPort(TEST_URL); + is( + port2.browser, + gBrowser.selectedBrowser, + "Port is for the correct browser" + ); + + port2.addMessageListener("Pong", failOnMessage); + port1.sendAsyncMessage("Ping", { str: "foobar", counter: 0 }); + let message = await waitForMessage(port1, "Pong"); + port2.removeMessageListener("Pong", failOnMessage); + is(message.data.str, "foobar", "String should pass through"); + is(message.data.counter, 1, "Counter should be incremented"); + + port1.addMessageListener("Pong", failOnMessage); + port2.sendAsyncMessage("Ping", { str: "foobaz", counter: 5 }); + message = await waitForMessage(port2, "Pong"); + port1.removeMessageListener("Pong", failOnMessage); + is(message.data.str, "foobaz", "String should pass through"); + is(message.data.counter, 6, "Counter should be incremented"); + + let unloadPromise = waitForMessage(port2, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(port2.browser)); + await unloadPromise; + + try { + port2.addMessageListener("Pong", failOnMessage); + ok( + false, + "Should not have been able to add a new message listener to a destroyed port." + ); + } catch (e) { + ok( + true, + "Should not have been able to add a new message listener to a destroyed port." + ); + } + + port1.sendAsyncMessage("Ping", { str: "foobar", counter: 0 }); + message = await waitForMessage(port1, "Pong"); + is(message.data.str, "foobar", "String should pass through"); + is(message.data.counter, 1, "Counter should be incremented"); + + unloadPromise = waitForMessage(port1, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(port1.browser)); + await unloadPromise; +}); + +// Tests that swapping browser docshells doesn't break the ports +add_task(async function browser_switch() { + let port1 = await waitForPort(TEST_URL); + is( + port1.browser, + gBrowser.selectedBrowser, + "Port is for the correct browser" + ); + let browser1 = gBrowser.selectedBrowser; + port1.sendAsyncMessage("SetCookie", { value: "om nom" }); + + let port2 = await waitForPort(TEST_URL); + is( + port2.browser, + gBrowser.selectedBrowser, + "Port is for the correct browser" + ); + let browser2 = gBrowser.selectedBrowser; + port2.sendAsyncMessage("SetCookie", { value: "om nom nom" }); + + port2.addMessageListener("Cookie", failOnMessage); + port1.sendAsyncMessage("GetCookie"); + let message = await waitForMessage(port1, "Cookie"); + port2.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom", "Should have the right cookie"); + + port1.addMessageListener("Cookie", failOnMessage); + port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 }); + message = await waitForMessage(port2, "Cookie"); + port1.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom nom", "Should have the right cookie"); + + swapDocShells(browser1, browser2); + is(port1.browser, browser2, "Should have noticed the swap"); + is(port2.browser, browser1, "Should have noticed the swap"); + + // Cookies should have stayed the same + port2.addMessageListener("Cookie", failOnMessage); + port1.sendAsyncMessage("GetCookie"); + message = await waitForMessage(port1, "Cookie"); + port2.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom", "Should have the right cookie"); + + port1.addMessageListener("Cookie", failOnMessage); + port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 }); + message = await waitForMessage(port2, "Cookie"); + port1.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom nom", "Should have the right cookie"); + + swapDocShells(browser1, browser2); + is(port1.browser, browser1, "Should have noticed the swap"); + is(port2.browser, browser2, "Should have noticed the swap"); + + // Cookies should have stayed the same + port2.addMessageListener("Cookie", failOnMessage); + port1.sendAsyncMessage("GetCookie"); + message = await waitForMessage(port1, "Cookie"); + port2.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom", "Should have the right cookie"); + + port1.addMessageListener("Cookie", failOnMessage); + port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 }); + message = await waitForMessage(port2, "Cookie"); + port1.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom nom", "Should have the right cookie"); + + let unloadPromise = waitForMessage(port2, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(browser2)); + await unloadPromise; + + unloadPromise = waitForMessage(port1, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(browser1)); + await unloadPromise; +}); + +// Tests that removeMessageListener in chrome works +add_task(async function remove_chrome_listener() { + let port = await waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + // This relies on messages sent arriving in the same order. Pong will be + // sent back before Pong2 so if removeMessageListener fails the test will fail + port.addMessageListener("Pong", failOnMessage); + port.removeMessageListener("Pong", failOnMessage); + port.sendAsyncMessage("Ping", { str: "remove_listener", counter: 27 }); + port.sendAsyncMessage("Ping2"); + await waitForMessage(port, "Pong2"); + + let unloadPromise = waitForMessage(port, "RemotePage:Unload"); + gBrowser.removeCurrentTab(); + await unloadPromise; +}); + +// Tests that removeMessageListener in content works +add_task(async function remove_content_listener() { + let port = await waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + // This relies on messages sent arriving in the same order. Pong3 would be + // sent back before Pong2 so if removeMessageListener fails the test will fail + port.addMessageListener("Pong3", failOnMessage); + port.sendAsyncMessage("Ping3"); + port.sendAsyncMessage("Ping2"); + await waitForMessage(port, "Pong2"); + + let unloadPromise = waitForMessage(port, "RemotePage:Unload"); + gBrowser.removeCurrentTab(); + await unloadPromise; +}); + +// Test RemotePages works +add_task(async function remote_pages_basic() { + let pages = new RemotePages(TEST_URL); + let port = await waitForPage(pages); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + // Listening to global messages should work + let unloadPromise = waitForMessage(pages, "RemotePage:Unload", port); + gBrowser.removeCurrentTab(); + await unloadPromise; + + pages.destroy(); + + // RemotePages should be destroyed now + try { + pages.addMessageListener("Foo", failOnMessage); + ok(false, "Should have seen exception"); + } catch (e) { + ok(true, "Should have seen exception"); + } + + try { + pages.sendAsyncMessage("Foo"); + ok(false, "Should have seen exception"); + } catch (e) { + ok(true, "Should have seen exception"); + } +}); + +// Test that properties exist on the target port provided to listeners +add_task(async function check_port_properties() { + let pages = new RemotePages(TEST_URL); + + const expectedProperties = [ + "addMessageListener", + "browser", + "destroy", + "loaded", + "portID", + "removeMessageListener", + "sendAsyncMessage", + "url", + ]; + function checkProperties(port, description) { + const expected = []; + const unexpected = []; + for (const key in port) { + (expectedProperties.includes(key) ? expected : unexpected).push(key); + } + is( + `${expected.sort()}`, + `${expectedProperties}`, + `${description} has expected keys` + ); + is( + `${unexpected.sort()}`, + "", + `${description} should not have unexpected keys` + ); + } + + function portFrom(message, extraFn = () => {}) { + return new Promise(resolve => { + function onMessage({ target }) { + pages.removeMessageListener(message, onMessage); + resolve(target); + } + pages.addMessageListener(message, onMessage); + extraFn(); + }); + } + + let portFromInit = await portFrom( + "RemotePage:Init", + () => (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_URL)) + ); + checkProperties(portFromInit, "inited port"); + ok( + ["about:blank", TEST_URL].includes(portFromInit.browser.currentURI.spec), + `inited port browser is either still blank or already at the target url - got ${portFromInit.browser.currentURI.spec}` + ); + is(portFromInit.loaded, false, "inited port has not been loaded yet"); + is(portFromInit.url, TEST_URL, "got expected url"); + + let portFromLoad = await portFrom("RemotePage:Load"); + is(portFromLoad, portFromInit, "got the same port from init and load"); + checkProperties(portFromLoad, "loaded port"); + is( + portFromInit.browser.currentURI.spec, + TEST_URL, + "loaded port has browser with actual url" + ); + is(portFromInit.loaded, true, "loaded port is now loaded"); + is(portFromInit.url, TEST_URL, "still got expected url"); + + let portFromUnload = await portFrom("RemotePage:Unload", () => + BrowserTestUtils.removeTab(gBrowser.selectedTab) + ); + is(portFromUnload, portFromInit, "got the same port from init and unload"); + checkProperties(portFromUnload, "unloaded port"); + is(portFromInit.browser, null, "unloaded port has no browser"); + is(portFromInit.loaded, false, "unloaded port is now not loaded"); + is(portFromInit.url, TEST_URL, "still got expected url"); + + pages.destroy(); +}); + +// Test sending messages to all remote pages works +add_task(async function remote_pages_multiple_pages() { + let pages = new RemotePages(TEST_URL); + let port1 = await waitForPage(pages); + let port2 = await waitForPage(pages); + + let pongPorts = []; + await new Promise(resolve => { + function listener({ name, target, data }) { + is(name, "Pong", "Should have seen the right response."); + is(data.str, "remote_pages", "String should pass through"); + is(data.counter, 43, "Counter should be incremented"); + pongPorts.push(target); + if (pongPorts.length == 2) { + resolve(); + } + } + + pages.addMessageListener("Pong", listener); + pages.sendAsyncMessage("Ping", { str: "remote_pages", counter: 42 }); + }); + + // We don't make any guarantees about which order messages are sent to known + // pages so the pongs could have come back in any order. + isnot( + pongPorts[0], + pongPorts[1], + "Should have received pongs from different ports" + ); + ok(pongPorts.includes(port1), "Should have seen a pong from port1"); + ok(pongPorts.includes(port2), "Should have seen a pong from port2"); + + // After destroy we should see no messages + pages.addMessageListener("RemotePage:Unload", failOnMessage); + pages.destroy(); + + gBrowser.removeTab(gBrowser.getTabForBrowser(port1.browser)); + gBrowser.removeTab(gBrowser.getTabForBrowser(port2.browser)); +}); + +// Test that RemotePages with multiple urls works +add_task(async function remote_pages_multiple_urls() { + const TEST_URLS = [TEST_URL, TEST_URL.replace(".html", "2.html")]; + const pages = new RemotePages(TEST_URLS); + + const ports = []; + // Load two pages for each url + for (const [i, url] of TEST_URLS.entries()) { + const port = await waitForPage(pages, url); + is( + port.browser, + gBrowser.selectedBrowser, + `port${i} is for the correct browser` + ); + ports.push(port); + ports.push(await waitForPage(pages, url)); + } + + let unloadPromise = waitForMessage(pages, "RemotePage:Unload", ports.pop()); + gBrowser.removeCurrentTab(); + await unloadPromise; + + const pongPorts = new Set(); + await new Promise(resolve => { + function listener({ name, target, data }) { + is(name, "Pong", "Should have seen the right response."); + is(data.str, "FAKE_DATA", "String should pass through"); + is(data.counter, 1235, "Counter should be incremented"); + pongPorts.add(target); + if (pongPorts.size === ports.length) { + resolve(); + } + } + + pages.addMessageListener("Pong", listener); + pages.sendAsyncMessage("Ping", { str: "FAKE_DATA", counter: 1234 }); + }); + + ports.forEach(port => ok(pongPorts.has(port))); + + pages.destroy(); + ports.forEach(port => + gBrowser.removeTab(gBrowser.getTabForBrowser(port.browser)) + ); +}); + +// Test sending various types of data across the boundary +add_task(async function send_data() { + let port = await waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let data = { + integer: 45, + real: 45.78, + str: "foobar", + array: [1, 2, 3, 5, 27], + }; + + port.sendAsyncMessage("SendData", data); + let message = await waitForMessage(port, "ReceivedData"); + + ok(message.data.result, message.data.status); + + gBrowser.removeCurrentTab(); +}); + +// Test sending an object of data across the boundary +add_task(async function send_data2() { + let port = await waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let data = { + integer: 45, + real: 45.78, + str: "foobar", + array: [1, 2, 3, 5, 27], + }; + + port.sendAsyncMessage("SendData2", { data }); + let message = await waitForMessage(port, "ReceivedData2"); + + ok(message.data.result, message.data.status); + + gBrowser.removeCurrentTab(); +}); + +add_task(async function get_ports_for_browser() { + let pages = new RemotePages(TEST_URL); + let port = await waitForPage(pages); + // waitForPage creates a new tab and selects it by default, so + // the selected tab should be the one hosting this port. + let browser = gBrowser.selectedBrowser; + let foundPorts = pages.portsForBrowser(browser); + is( + foundPorts.length, + 1, + "There should only be one port for this simple page" + ); + is(foundPorts[0], port, "Should find the port"); + + pages.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager.html b/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager.html new file mode 100644 index 0000000000..a1ad6cffb1 --- /dev/null +++ b/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager.html @@ -0,0 +1,68 @@ +<!DOCTYPE HTML> + +<html> +<head> +<script type="text/javascript"> +/* global RPMAddMessageListener, RPMSendAsyncMessage, RPMRemoveMessageListener */ + +RPMAddMessageListener("Ping", function(message) { + RPMSendAsyncMessage("Pong", { + str: message.data.str, + counter: message.data.counter + 1, + }); +}); + +RPMAddMessageListener("Ping2", function(message) { + RPMSendAsyncMessage("Pong2", message.data); +}); + +function neverCalled() { + RPMSendAsyncMessage("Pong3"); +} +RPMAddMessageListener("Pong3", neverCalled); +RPMRemoveMessageListener("Pong3", neverCalled); + +function testData(data) { + var response = { + result: true, + status: "All data correctly received", + }; + + function compare(prop, expected) { + if (JSON.stringify(data[prop]) == JSON.stringify(expected)) + return; + if (response.result) + response.status = ""; + response.result = false; + response.status += "Property " + prop + " should have been " + expected + " but was " + data[prop] + "\n"; + } + + compare("integer", 45); + compare("real", 45.78); + compare("str", "foobar"); + compare("array", [1, 2, 3, 5, 27]); + + return response; +} + +RPMAddMessageListener("SendData", function(message) { + RPMSendAsyncMessage("ReceivedData", testData(message.data)); +}); + +RPMAddMessageListener("SendData2", function(message) { + RPMSendAsyncMessage("ReceivedData2", testData(message.data.data)); +}); + +var cookie = "nom"; +RPMAddMessageListener("SetCookie", function(message) { + cookie = message.data.value; +}); + +RPMAddMessageListener("GetCookie", function(message) { + RPMSendAsyncMessage("Cookie", { value: cookie }); +}); +</script> +</head> +<body> +</body> +</html> diff --git a/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager2.html b/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager2.html new file mode 100644 index 0000000000..70784b5011 --- /dev/null +++ b/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager2.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<!-- A second page to test that RemotePages works with multiple urls --> +<html> +<head> +<script type="text/javascript"> +/* global RPMAddMessageListener, RPMSendAsyncMessage */ + +RPMAddMessageListener("Ping", function(message) { + RPMSendAsyncMessage("Pong", { + str: message.data.str, + counter: message.data.counter + 1, + }); +}); + +</script> +</head> +<body> +</body> +</html> |