Bug 1713442 - [remote] Support events in windowglobal MessageHandlers r=webdriver-reviewers,whimboo

Differential Revision: https://phabricator.services.mozilla.com/D120640
This commit is contained in:
Julian Descottes 2021-08-10 21:34:23 +00:00
parent 1e11a69a62
commit aed2bec56c
13 changed files with 523 additions and 50 deletions

View File

@ -88,6 +88,28 @@ class MessageHandler extends EventEmitter {
this.emit("message-handler-destroyed", this);
}
/**
* Emit a message-handler-event. Such events should bubble up to the root of
* a MessageHandler network.
*
* @param {String} method
* A string literal of the form [module name].[event name]. This is the
* event name.
* @param {Object} params
* The event parameters.
*/
emitMessageHandlerEvent(method, params) {
this.emit("message-handler-event", {
// TODO: The messageHandlerInfo needs to be wrapped in the event so
// that consumers can check the type/context. Once MessageHandlerRegistry
// becomes context-specific (Bug 1722659), only the sessionId will be
// required.
messageHandlerInfo: this._messageHandlerInfo,
method,
params,
});
}
/**
* @typedef {Object} CommandDestination
* @property {String} type - One of MessageHandler.type.

View File

@ -11,6 +11,8 @@ const { XPCOMUtils } = ChromeUtils.import(
);
XPCOMUtils.defineLazyModuleGetters(this, {
EventEmitter: "resource://gre/modules/EventEmitter.jsm",
Log: "chrome://remote/content/shared/Log.jsm",
MessageHandlerInfo:
"chrome://remote/content/shared/messagehandler/MessageHandlerInfo.jsm",
@ -61,8 +63,10 @@ function getMessageHandlerClass(type) {
*
* Note: this is still created as a class, but exposed as a singleton.
*/
class MessageHandlerRegistryClass {
class MessageHandlerRegistryClass extends EventEmitter {
constructor() {
super();
/**
* Map of all message handlers registered in this process.
* Keys are based on session id, message handler type and message handler
@ -75,6 +79,7 @@ class MessageHandlerRegistryClass {
this._onMessageHandlerDestroyed = this._onMessageHandlerDestroyed.bind(
this
);
this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this);
}
/**
@ -154,6 +159,30 @@ class MessageHandlerRegistryClass {
return messageHandler;
}
/**
* Retrieve an already registered RootMessageHandler instance matching the
* provided sessionId.
*
* @param {String} sessionId
* ID of the session the handler is used for.
* @return {RootMessageHandler}
* A RootMessageHandler instance.
* @throws {Error}
* If no root MessageHandler can be found for the provided session id.
*/
getRootMessageHandler(sessionId) {
const rootMessageHandler = this.getExistingMessageHandler(
sessionId,
RootMessageHandler.type
);
if (!rootMessageHandler) {
throw new Error(
`Unable to find a root MessageHandler for session id ${sessionId}`
);
}
return rootMessageHandler;
}
toString() {
return `[object ${this.constructor.name}]`;
}
@ -182,6 +211,7 @@ class MessageHandlerRegistryClass {
"message-handler-destroyed",
this._onMessageHandlerDestroyed
);
messageHandler.on("message-handler-event", this._onMessageHandlerEvent);
return messageHandler;
}
@ -198,13 +228,20 @@ class MessageHandlerRegistryClass {
// Event handlers
_onMessageHandlerDestroyed(evt, messageHandler) {
_onMessageHandlerDestroyed(eventName, messageHandler) {
messageHandler.off(
"message-handler-destroyed",
this._onMessageHandlerDestroyed
);
messageHandler.off("message-handler-event", this._onMessageHandlerEvent);
this._unregisterMessageHandler(messageHandler);
}
_onMessageHandlerEvent(eventName, messageHandlerEvent) {
// The registry simply re-emits MessageHandler events so that consumers
// don't have to attach listeners to individual MessageHandler instances.
this.emit("message-handler-registry-event", messageHandlerEvent);
}
}
const MessageHandlerRegistry = new MessageHandlerRegistryClass();

View File

@ -6,37 +6,10 @@
add_task(async function test_broadcasting_with_frames() {
info("Navigate the initial tab to the test URL");
const tab = gBrowser.selectedTab;
// Create a test page with 2 iframes:
// - one with a different eTLD+1 (example.com)
// - one with a nested iframe on a different eTLD+1 (example.net)
//
// Overall the document structure should look like:
//
// html (example.org)
// iframe (example.org)
// iframe (example.net)
// iframe(example.com)
//
// Which means we should have 4 browsing contexts in total.
// Create the markup for an example.net frame nested in an example.com frame.
const NESTED_FRAME_MARKUP = createFrameForUri(
`http://example.org/document-builder.sjs?html=${createFrame("example.net")}`
);
// Combine the nested frame markup created above with an example.com frame.
const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`;
// Create the test page URI on example.org.
const TEST_URI = `http://example.org/document-builder.sjs?html=${encodeURI(
TEST_URI_MARKUP
)}`;
await loadURL(tab.linkedBrowser, TEST_URI);
await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
const contexts = tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
is(contexts.length, 4, "Test tab has 3 children contexts");
is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
const rootMessageHandler = createRootMessageHandler(
"session-id-broadcasting_with_frames"
@ -63,20 +36,3 @@ add_task(async function test_broadcasting_with_frames() {
rootMessageHandler.destroy();
});
/**
* Create inline markup for a simple iframe that can be used with
* document-builder.sjs. The iframe will be served under the provided domain.
*
* @param {String} domain
* A domain (eg "example.com"), compatible with build/pgo/server-locations.txt
*/
function createFrame(domain) {
return createFrameForUri(
`http://${domain}/document-builder.sjs?html=frame-${domain}`
);
}
function createFrameForUri(uri) {
return `<iframe src="${encodeURI(uri)}"></iframe>`;
}

View File

@ -7,6 +7,7 @@ support-files =
prefs =
remote.messagehandler.modulecache.useBrowserTestRoot=true
[browser_events.js]
[browser_handle_command_errors.js]
[browser_handle_simple_command.js]
[browser_registry.js]

View File

@ -0,0 +1,248 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const { MessageHandlerRegistry } = ChromeUtils.import(
"chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.jsm"
);
const { RootMessageHandler } = ChromeUtils.import(
"chrome://remote/content/shared/messagehandler/RootMessageHandler.jsm"
);
const { WindowGlobalMessageHandler } = ChromeUtils.import(
"chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.jsm"
);
/**
* Emit an event from a WindowGlobal module triggered by a specific command.
* Check that the event is emitted on the RootMessageHandler as well as on
* the parent process MessageHandlerRegistry.
*/
add_task(async function test_event() {
const tab = BrowserTestUtils.addTab(
gBrowser,
"http://example.com/document-builder.sjs?html=tab"
);
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
const browsingContext = tab.linkedBrowser.browsingContext;
const rootMessageHandler = createRootMessageHandler("session-id-event");
const onTestEvent = rootMessageHandler.once("message-handler-event");
// MessageHandlerRegistry should forward all the message-handler-events.
const onRegistryEvent = MessageHandlerRegistry.once(
"message-handler-registry-event"
);
callTestEmitEvent(rootMessageHandler, browsingContext.id);
const messageHandlerEvent = await onTestEvent;
is(
messageHandlerEvent.method,
"event.testEvent",
"Received event.testEvent on the ROOT MessageHandler"
);
is(
messageHandlerEvent.params.text,
`event from ${browsingContext.id}`,
"Received the expected data in testEvent"
);
const registryEvent = await onRegistryEvent;
is(
registryEvent,
messageHandlerEvent,
"The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event"
);
rootMessageHandler.destroy();
gBrowser.removeTab(tab);
});
/**
* Emit an event from a Root module triggered by a specific command.
* Check that the event is emitted on the RootMessageHandler.
*/
add_task(async function test_root_event() {
const rootMessageHandler = createRootMessageHandler("session-id-root_event");
const onTestEvent = rootMessageHandler.once("message-handler-event");
rootMessageHandler.handleCommand({
moduleName: "event",
commandName: "testEmitRootEvent",
destination: {
type: RootMessageHandler.type,
},
});
const { method, params } = await onTestEvent;
is(
method,
"event.testRootEvent",
"Received event.testRootEvent on the ROOT MessageHandler"
);
is(
params.text,
"event from root",
"Received the expected payload in testRootEvent"
);
rootMessageHandler.destroy();
});
/**
* Emit an event from a windowglobal-in-root module triggered by a specific command.
* Check that the event is emitted on the RootMessageHandler.
*/
add_task(async function test_windowglobal_in_root_event() {
const tab = BrowserTestUtils.addTab(
gBrowser,
"http://example.com/document-builder.sjs?html=tab"
);
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
const browsingContext = tab.linkedBrowser.browsingContext;
const rootMessageHandler = createRootMessageHandler(
"session-id-windowglobal_in_root_event"
);
const onTestEvent = rootMessageHandler.once("message-handler-event");
rootMessageHandler.handleCommand({
moduleName: "event",
commandName: "testEmitWindowGlobalInRootEvent",
destination: {
type: WindowGlobalMessageHandler.type,
id: browsingContext.id,
},
});
const { method, params } = await onTestEvent;
is(
method,
"event.testWindowGlobalInRootEvent",
"Received event.testWindowGlobalInRoot on the ROOT MessageHandler"
);
is(
params.text,
`windowglobal-in-root event for ${browsingContext.id}`,
"Received the expected payload in testWindowGlobalInRoot"
);
rootMessageHandler.destroy();
gBrowser.removeTab(tab);
});
/**
* Emit an event from a windowglobal module, but from 2 different sessions.
* Check that the event is emitted by the corresponding RootMessageHandler as
* well as by the parent process MessageHandlerRegistry.
*/
add_task(async function test_event_multisession() {
const tab = BrowserTestUtils.addTab(
gBrowser,
"http://example.com/document-builder.sjs?html=tab"
);
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
const browsingContextId = tab.linkedBrowser.browsingContext.id;
const root1 = createRootMessageHandler("session-id-event_multisession-1");
let root1Events = 0;
const onRoot1Event = function(evtName, wrappedEvt) {
if (wrappedEvt.method === "event.testEvent") {
root1Events++;
}
};
root1.on("message-handler-event", onRoot1Event);
const root2 = createRootMessageHandler("session-id-event_multisession-2");
let root2Events = 0;
const onRoot2Event = function(evtName, wrappedEvt) {
if (wrappedEvt.method === "event.testEvent") {
root2Events++;
}
};
root2.on("message-handler-event", onRoot2Event);
let registryEvents = 0;
const onRegistryEvent = function(evtName, wrappedEvt) {
if (wrappedEvt.method === "event.testEvent") {
registryEvents++;
}
};
MessageHandlerRegistry.on("message-handler-registry-event", onRegistryEvent);
callTestEmitEvent(root1, browsingContextId);
callTestEmitEvent(root2, browsingContextId);
info("Wait for root1 event to be received");
await TestUtils.waitForCondition(() => root1Events === 1);
info("Wait for root2 event to be received");
await TestUtils.waitForCondition(() => root2Events === 1);
await TestUtils.waitForTick();
is(root1Events, 1, "Session 1 only received 1 event");
is(root2Events, 1, "Session 2 only received 1 event");
is(
registryEvents,
2,
"MessageHandlerRegistry forwarded events from both sessions"
);
root1.off("message-handler-event", onRoot1Event);
root2.off("message-handler-event", onRoot2Event);
MessageHandlerRegistry.off("message-handler-registry-event", onRegistryEvent);
root1.destroy();
root2.destroy();
gBrowser.removeTab(tab);
});
/**
* Test that events can be emitted from individual frame contexts and that
* events going through a shared content process MessageHandlerRegistry are not
* duplicated.
*/
add_task(async function test_event_with_frames() {
info("Navigate the initial tab to the test URL");
const tab = gBrowser.selectedTab;
await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
const contexts = tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
const rootMessageHandler = createRootMessageHandler(
"session-id-event_with_frames"
);
let rootEvents = [];
const onRootEvent = function(evtName, wrappedEvt) {
if (wrappedEvt.method === "event.testEvent") {
rootEvents.push(wrappedEvt.params.text);
}
};
rootMessageHandler.on("message-handler-event", onRootEvent);
for (const context of contexts) {
callTestEmitEvent(rootMessageHandler, context.id);
info("Wait for root event to be received");
await TestUtils.waitForCondition(() =>
rootEvents.includes(`event from ${context.id}`)
);
}
info("Wait for a bit and check that we did not receive duplicated events");
await TestUtils.waitForTick();
is(rootEvents.length, 4, "Only received 4 events");
rootMessageHandler.off("message-handler-event", onRootEvent);
rootMessageHandler.destroy();
});
function callTestEmitEvent(rootMessageHandler, browsingContextId) {
rootMessageHandler.handleCommand({
moduleName: "event",
commandName: "testEmitEvent",
destination: {
type: WindowGlobalMessageHandler.type,
id: browsingContextId,
},
});
}

View File

@ -46,3 +46,47 @@ async function addTab(url) {
});
return tab;
}
/**
* Create inline markup for a simple iframe that can be used with
* document-builder.sjs. The iframe will be served under the provided domain.
*
* @param {String} domain
* A domain (eg "example.com"), compatible with build/pgo/server-locations.txt
*/
function createFrame(domain) {
return createFrameForUri(
`http://${domain}/document-builder.sjs?html=frame-${domain}`
);
}
function createFrameForUri(uri) {
return `<iframe src="${encodeURI(uri)}"></iframe>`;
}
// Create a test page with 2 iframes:
// - one with a different eTLD+1 (example.com)
// - one with a nested iframe on a different eTLD+1 (example.net)
//
// Overall the document structure should look like:
//
// html (example.org)
// iframe (example.org)
// iframe (example.net)
// iframe(example.com)
//
// Which means we should have 4 browsing contexts in total.
function createTestMarkupWithFrames() {
// Create the markup for an example.net frame nested in an example.com frame.
const NESTED_FRAME_MARKUP = createFrameForUri(
`http://example.org/document-builder.sjs?html=${createFrame("example.net")}`
);
// Combine the nested frame markup created above with an example.com frame.
const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`;
// Create the test page URI on example.org.
return `http://example.org/document-builder.sjs?html=${encodeURI(
TEST_URI_MARKUP
)}`;
}

View File

@ -0,0 +1,27 @@
/* 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 EXPORTED_SYMBOLS = ["event"];
class Event {
constructor(messageHandler) {
this.messageHandler = messageHandler;
}
destroy() {}
/**
* Commands
*/
testEmitRootEvent() {
this.messageHandler.emitMessageHandlerEvent("event.testRootEvent", {
text: "event from root",
});
}
}
const event = Event;

View File

@ -0,0 +1,28 @@
/* 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 EXPORTED_SYMBOLS = ["event"];
class Event {
constructor(messageHandler) {
this.messageHandler = messageHandler;
}
destroy() {}
/**
* Commands
*/
testEmitWindowGlobalInRootEvent(params, destination) {
this.messageHandler.emitMessageHandlerEvent(
"event.testWindowGlobalInRootEvent",
{ text: `windowglobal-in-root event for ${destination.id}` }
);
}
}
const event = Event;

View File

@ -0,0 +1,28 @@
/* 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 EXPORTED_SYMBOLS = ["event"];
class Event {
constructor(messageHandler) {
this.messageHandler = messageHandler;
}
destroy() {}
/**
* Commands
*/
testEmitEvent() {
// Emit a payload including the contextId to check which context emitted
// a specific event.
const text = `event from ${this.messageHandler.contextId}`;
this.messageHandler.emitMessageHandlerEvent("event.testEvent", { text });
}
}
const event = Event;

View File

@ -26,6 +26,17 @@ class MessageHandlerFrameChild extends JSWindowActorChild {
actorCreated() {
this.type = WindowGlobalMessageHandler.type;
this.context = this.manager.browsingContext;
this._onRegistryEvent = this._onRegistryEvent.bind(this);
// MessageHandlerFrameChild is responsible for forwarding events from
// WindowGlobalMessageHandler to the parent process.
// Such events are re-emitted on the MessageHandlerRegistry to avoid
// setting up listeners on individual MessageHandler instances.
MessageHandlerRegistry.on(
"message-handler-registry-event",
this._onRegistryEvent
);
}
receiveMessage(message) {
@ -53,7 +64,31 @@ class MessageHandlerFrameChild extends JSWindowActorChild {
);
}
_onRegistryEvent(eventName, wrappedEvent) {
const { messageHandlerInfo, method, params } = wrappedEvent;
const { contextId, sessionId, type } = messageHandlerInfo;
// TODO: With a single MessageHandlerRegistry per process, we might receive
// events intended for other contexts. Consequently we have to filter out
// unrelevant events. Once Registry becomes context-specific (Bug 1722659)
// this filtering should be removed.
if (
type === this.type &&
contextId === WindowGlobalMessageHandler.getIdFromContext(this.context)
) {
this.sendAsyncMessage("MessageHandlerFrameChild:messageHandlerEvent", {
method,
params,
sessionId,
});
}
}
didDestroy() {
MessageHandlerRegistry.contextDestroyed(this.context, this.type);
MessageHandlerRegistry.off(
"message-handler-registry-event",
this._onRegistryEvent
);
}
}

View File

@ -6,12 +6,39 @@
var EXPORTED_SYMBOLS = ["MessageHandlerFrameParent"];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
MessageHandlerRegistry:
"chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.jsm",
});
/**
* Parent actor for the MessageHandlerFrame JSWindowActor. The
* MessageHandlerFrame actor is used by FrameTransport to communicate between
* ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
*/
class MessageHandlerFrameParent extends JSWindowActorParent {
receiveMessage(message) {
switch (message.name) {
case "MessageHandlerFrameChild:messageHandlerEvent":
const { method, params, sessionId } = message.data;
const messageHandler = MessageHandlerRegistry.getRootMessageHandler(
sessionId
);
// Re-emit the event on the RootMessageHandler.
messageHandler.emitMessageHandlerEvent(method, params);
break;
default:
throw new Error("Unsupported message:" + message.name);
}
}
/**
* Send a command to the corresponding MessageHandlerFrameChild actor via a
* JSWindowActor query.

View File

@ -211,7 +211,13 @@ class WebDriverSession {
this._connections.clear();
// Destroy the dedicated MessageHandler instance if we created one.
this._messageHandler?.destroy();
if (this._messageHandler) {
this._messageHandler.off(
"message-handler-event",
this._onMessageHandlerEvent
);
this._messageHandler.destroy();
}
}
execute(module, command, params) {
@ -242,6 +248,11 @@ class WebDriverSession {
this.id,
RootMessageHandler.type
);
this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this);
this._messageHandler.on(
"message-handler-event",
this._onMessageHandlerEvent
);
}
return this._messageHandler;
@ -296,6 +307,13 @@ class WebDriverSession {
this._connections.add(conn);
}
_onMessageHandlerEvent(eventName, messageHandlerEvent) {
const { method, params } = messageHandlerEvent;
this._connections.forEach(connection =>
connection.sendEvent(method, params)
);
}
// XPCOM
get QueryInterface() {

View File

@ -93,7 +93,9 @@ class WebDriverBiDiConnection extends WebSocketConnection {
* @param {Object} params
* A JSON-serializable object, which is the payload of this event.
*/
sendEvent(method, params) {}
sendEvent(method, params) {
this.send({ method, params });
}
/**
* Send the result of a call to a module's method back to the