summaryrefslogtreecommitdiffstats
path: root/toolkit/components/remotepagemanager
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/remotepagemanager
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--toolkit/components/remotepagemanager/MessagePort.jsm280
-rw-r--r--toolkit/components/remotepagemanager/RemotePageManagerChild.jsm87
-rw-r--r--toolkit/components/remotepagemanager/RemotePageManagerParent.jsm351
-rw-r--r--toolkit/components/remotepagemanager/moz.build16
-rw-r--r--toolkit/components/remotepagemanager/tests/browser/browser.ini6
-rw-r--r--toolkit/components/remotepagemanager/tests/browser/browser_RemotePageManager.js570
-rw-r--r--toolkit/components/remotepagemanager/tests/browser/testremotepagemanager.html68
-rw-r--r--toolkit/components/remotepagemanager/tests/browser/testremotepagemanager2.html19
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>