Bug 1563692 - Move all CDP's JSON packet handling to Connection. r=remote-protocol-reviewers,jdescottes,ato
authorAlexandre Poirot <poirot.alex@gmail.com>
Thu, 11 Jul 2019 17:45:29 +0000
changeset 482465 6a51231751f346ff530f6addae1bd281a567ff9b
parent 482464 78deb0f239034979f37df845acba79b5a267ebad
child 482466 7aae033c699d432aedc78bcbbcde56c542442f3a
push id89794
push userapoirot@mozilla.com
push dateThu, 11 Jul 2019 21:03:40 +0000
treeherderautoland@6a51231751f3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersremote-protocol-reviewers, jdescottes, ato
bugs1563692
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1563692 - Move all CDP's JSON packet handling to Connection. r=remote-protocol-reviewers,jdescottes,ato This helps sharing a single implementation of how the JSON objects are read and written from/to the WebSocket connection. Also, by delegating the command calls to the Session via `Session.execute` and expecting a promise with a resolution or rejection, we make error handling of command calls clearer and unified. Differential Revision: https://phabricator.services.mozilla.com/D37046
remote/Connection.jsm
remote/domains/Domains.jsm
remote/domains/parent/Target.jsm
remote/jar.mn
remote/sessions/ContentProcessSession.jsm
remote/sessions/MainProcessSession.jsm
remote/sessions/Session.jsm
remote/sessions/TabSession.jsm
remote/targets/MainProcessTarget.jsm
remote/test/browser/browser_session.js
remote/test/browser/browser_target.js
remote/test/unit/test_Connection.js
remote/test/unit/test_Domains.js
remote/test/unit/xpcshell.ini
--- a/remote/Connection.jsm
+++ b/remote/Connection.jsm
@@ -57,66 +57,221 @@ class Connection {
     this.sessions.set(session.id, session);
   }
 
   send(message) {
     log.trace(`<-(connection ${this.id}) ${JSON.stringify(message)}`);
     this.transport.send(message);
   }
 
-  error(id, e) {
+  /**
+   * Send an error back to the client.
+   *
+   * @param Number id
+   *        Id of the packet which lead to an error.
+   * @param Error e
+   *        Error object with `message` and `stack` attributes.
+   * @param Number sessionId (Optional)
+   *        Id of the session used to send this packet.
+   *        This will be null if that was the default session.
+   */
+  onError(id, e, sessionId) {
     const error = {
       message: e.message,
       data: e.stack,
     };
-    this.send({ id, error });
+    this.send({ id, sessionId, error });
+  }
+
+  /**
+   * Send the result of a call to a Domain's function.
+   *
+   * @param Number id
+   *        The request id being sent by the client to call the domain's method.
+   * @param Object result
+   *        A JSON-serializable value which is the actual result.
+   * @param Number sessionId
+   *        The sessionId from which this packet is emitted.
+   *        This will be undefined for the default session.
+   */
+  onResult(id, result, sessionId) {
+    this.sendResult(id, result, sessionId);
+
+    // When a client attaches to a secondary target via
+    // `Target.attachToTarget`, and it executes a command via
+    // `Target.sendMessageToTarget`, we should emit an event back with the
+    // result including the `sessionId` attribute of this secondary target's
+    // session. `Target.attachToTarget` creates the secondary session and
+    // returns the session ID.
+    if (sessionId) {
+      this.sendEvent("Target.receivedMessageFromTarget", {
+        sessionId,
+        // receivedMessageFromTarget is expected to send a raw CDP packet
+        // in the `message` property and it to be already serialized to a
+        // string
+        message: JSON.stringify({
+          id,
+          result,
+        }),
+      });
+    }
   }
 
-  deserialize(data) {
-    const id = data.id;
-    const method = data.method;
-    const params = data.params || {};
-    return { id, method, params };
+  sendResult(id, result, sessionId) {
+    this.send({
+      sessionId, // this will be undefined for the default session
+      id,
+      result,
+    });
+  }
+
+  /**
+   * Send an event coming from a Domain to the CDP client.
+   *
+   * @param String method
+   *        The event name. This is composed by a domain name,
+   *        a dot character followed by the event name.
+   *        e.g. `Target.targetCreated`
+   * @param Object params
+   *        A JSON-serializable value which is the payload
+   *        associated with this event.
+   * @param Number sessionId
+   *        The sessionId from which this packet is emitted.
+   *        This will be undefined for the default session.
+   */
+  onEvent(method, params, sessionId) {
+    this.sendEvent(method, params, sessionId);
+
+    // When a client attaches to a secondary target via
+    // `Target.attachToTarget`, we should emit an event back with the
+    // result including the `sessionId` attribute of this secondary target's
+    // session. `Target.attachToTarget` creates the secondary session and
+    // returns the session ID.
+    if (sessionId) {
+      this.sendEvent("Target.receivedMessageFromTarget", {
+        sessionId,
+        message: JSON.stringify({
+          method,
+          params,
+        }),
+      });
+    }
+  }
+
+  sendEvent(method, params, sessionId) {
+    this.send({
+      sessionId, // this will be undefined for the default session
+      method,
+      params,
+    });
   }
 
   // transport hooks
 
-  onPacket(packet) {
+  /**
+   * Receive a packet from the WebSocket layer.
+   * This packet is sent by a CDP client and is meant to execute
+   * a particular function on a given Domain.
+   *
+   * @param Object packet
+   *        JSON-serializable object sent by the client
+   */
+  async onPacket(packet) {
     log.trace(`(connection ${this.id})-> ${JSON.stringify(packet)}`);
 
-    let message = { id: null };
     try {
-      message = this.deserialize(packet);
-      const { sessionId } = packet;
+      const { id, method, params, sessionId } = packet;
+
+      // First check for mandatory field in the packets
+      if (typeof id == "undefined") {
+        throw new TypeError("Message missing 'id' field");
+      }
+      if (typeof method == "undefined") {
+        throw new TypeError("Message missing 'method' field");
+      }
+
+      // Extract the domain name and the method name out of `method` attribute
+      const { domain, command } = Connection.splitMethod(method);
+
+      // If a `sessionId` field is passed, retrieve the session to which we
+      // should forward this packet. Otherwise send it to the default session.
+      let session;
       if (!sessionId) {
         if (!this.defaultSession) {
           throw new Error(`Connection is missing a default Session.`);
         }
-        this.defaultSession.onMessage(message);
+        session = this.defaultSession;
       } else {
-        const session = this.sessions.get(sessionId);
+        session = this.sessions.get(sessionId);
         if (!session) {
           throw new Error(`Session '${sessionId}' doesn't exists.`);
         }
-        session.onMessage(message);
       }
+
+      // Finally, instruct the targeted session to execute the command
+      const result = await session.execute(id, domain, command, params);
+      this.onResult(id, result, sessionId);
     } catch (e) {
       log.warn(e);
-      this.error(message.id, e);
+      this.onError(packet.id, e, packet.sessionId);
     }
   }
 
+  /**
+   * Interpret a given CDP packet for a given Session.
+   *
+   * @param String sessionId
+   *               ID of the session for which we should execute a command.
+   * @param String message
+   *               JSON payload of the CDP packet stringified to a string.
+   *               The CDP packet is about executing a Domain's function.
+   */
+  sendMessageToTarget(sessionId, message) {
+    const session = this.sessions.get(sessionId);
+    if (!session) {
+      throw new Error(`Session '${sessionId}' doesn't exists.`);
+    }
+    // `message` is received from `Target.sendMessageToTarget` where the
+    // message attribute is a stringify JSON payload which represent a CDP
+    // packet.
+    const packet = JSON.parse(message);
+
+    // The CDP packet sent by the client shouldn't have a sessionId attribute
+    // as it is passed as another argument of `Target.sendMessageToTarget`.
+    // Set it here in order to reuse the codepath of flatten session, where
+    // the client sends CDP packets with a `sessionId` attribute instead
+    // of going through the old and probably deprecated
+    // `Target.sendMessageToTarget` API.
+    packet.sessionId = sessionId;
+    this.onPacket(packet);
+  }
+
   close() {
     this.transport.close();
     this.sessions.clear();
 
     // In addition to the WebSocket transport, we also have to close the Connection
     // used internaly within httpd.js. Otherwise the server doesn't shut down correctly
     // and keep these Connection instances alive.
     this.httpdConnection.close();
   }
 
   onClosed(status) {}
 
+  /**
+   * Splits a method, e.g. "Browser.getVersion",
+   * into domain ("Browser") and command ("getVersion") components.
+   */
+  static splitMethod(s) {
+    const ss = s.split(".");
+    if (ss.length != 2 || ss[0].length == 0 || ss[1].length == 0) {
+      throw new TypeError(`Invalid method format: "${s}"`);
+    }
+    return {
+      domain: ss[0],
+      command: ss[1],
+    };
+  }
+
   toString() {
     return `[object Connection ${this.id}]`;
   }
 }
--- a/remote/domains/Domains.jsm
+++ b/remote/domains/Domains.jsm
@@ -81,35 +81,33 @@ class Domains {
   has(name) {
     return name in this.modules;
   }
 
   get size() {
     return this.instances.size;
   }
 
+  /**
+   * Execute the given command (function) of a given domain with the given parameters.
+   * If the command doesn't exists, it will throw.
+   * It returns the returned value of the command, which is most likely a promise.
+   */
+  execute(domain, command, params) {
+    if (!this.domainSupportsMethod(domain, command)) {
+      throw new UnknownMethodError(domain, command);
+    }
+    const inst = this.get(domain);
+    return inst[command](params);
+  }
+
   /** Calls destructor on each domain and clears the cache. */
   clear() {
     for (const inst of this.instances.values()) {
       inst.destructor();
     }
     this.instances.clear();
   }
-
-  /**
-   * Splits a method, e.g. "Browser.getVersion",
-   * into domain ("Browser") and command ("getVersion") components.
-   */
-  static splitMethod(s) {
-    const ss = s.split(".");
-    if (ss.length != 2 || ss[0].length == 0 || ss[1].length == 0) {
-      throw new TypeError(`Invalid method format: "${s}"`);
-    }
-    return {
-      domain: ss[0],
-      command: ss[1],
-    };
-  }
 }
 
 function isConstructor(obj) {
   return !!obj.prototype && !!obj.prototype.constructor.name;
 }
--- a/remote/domains/parent/Target.jsm
+++ b/remote/domains/parent/Target.jsm
@@ -103,35 +103,29 @@ class Target extends Domain {
     const target = targets.getById(targetId);
     if (!target) {
       return new Error(`Unable to find target with id '${targetId}'`);
     }
 
     const session = new TabSession(
       this.session.connection,
       target,
-      sessionIds++,
-      this.session
+      sessionIds++
     );
     this.emit("Target.attachedToTarget", {
       targetInfo: {
         type: "page",
       },
       sessionId: session.id,
     });
 
     return {
       sessionId: session.id,
     };
   }
 
   setAutoAttach() {}
 
   sendMessageToTarget({ sessionId, message }) {
-    const { sessions } = this.session.connection;
-    const session = sessions.get(sessionId);
-    if (!session) {
-      throw new Error(`Session '${sessionId}' doesn't exists.`);
-    }
-    message = JSON.parse(message);
-    session.onMessage(message);
+    const { connection } = this.session;
+    connection.sendMessageToTarget(sessionId, message);
   }
 }
--- a/remote/jar.mn
+++ b/remote/jar.mn
@@ -14,16 +14,17 @@ remote.jar:
   content/Protocol.jsm (Protocol.jsm)
   content/RecommendedPreferences.jsm (RecommendedPreferences.jsm)
   content/Sync.jsm (Sync.jsm)
   content/WindowManager.jsm (WindowManager.jsm)
 
   # sessions
   content/sessions/frame-script.js (sessions/frame-script.js)
   content/sessions/ContentProcessSession.jsm (sessions/ContentProcessSession.jsm)
+  content/sessions/MainProcessSession.jsm (sessions/MainProcessSession.jsm)
   content/sessions/Session.jsm (sessions/Session.jsm)
   content/sessions/TabSession.jsm (sessions/TabSession.jsm)
 
   # targets
   content/targets/MainProcessTarget.jsm (targets/MainProcessTarget.jsm)
   content/targets/TabTarget.jsm (targets/TabTarget.jsm)
   content/targets/Target.jsm (targets/Target.jsm)
   content/targets/Targets.jsm (targets/Targets.jsm)
--- a/remote/sessions/ContentProcessSession.jsm
+++ b/remote/sessions/ContentProcessSession.jsm
@@ -7,19 +7,16 @@
 var EXPORTED_SYMBOLS = ["ContentProcessSession"];
 
 const { ContentProcessDomains } = ChromeUtils.import(
   "chrome://remote/content/domains/ContentProcessDomains.jsm"
 );
 const { Domains } = ChromeUtils.import(
   "chrome://remote/content/domains/Domains.jsm"
 );
-const { UnknownMethodError } = ChromeUtils.import(
-  "chrome://remote/content/Error.jsm"
-);
 
 class ContentProcessSession {
   constructor(messageManager, browsingContext, content, docShell) {
     this.messageManager = messageManager;
     this.browsingContext = browsingContext;
     this.content = content;
     this.docShell = docShell;
     // Most children or sibling classes are going to assume that docShell
@@ -63,21 +60,18 @@ class ContentProcessSession {
     if (browsingContextId != this.browsingContext.id) {
       return;
     }
 
     switch (name) {
       case "remote:request":
         try {
           const { id, domain, command, params } = data.request;
-          if (!this.domains.domainSupportsMethod(domain, command)) {
-            throw new UnknownMethodError(domain, command);
-          }
-          const inst = this.domains.get(domain);
-          const result = await inst[command](params);
+
+          const result = await this.domains.execute(domain, command, params);
 
           this.messageManager.sendAsyncMessage("remote:result", {
             browsingContextId,
             id,
             result,
           });
         } catch (e) {
           this.messageManager.sendAsyncMessage("remote:error", {
new file mode 100644
--- /dev/null
+++ b/remote/sessions/MainProcessSession.jsm
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["MainProcessSession"];
+
+const { Session } = ChromeUtils.import(
+  "chrome://remote/content/sessions/Session.jsm"
+);
+
+/**
+ * A session, dedicated to the main process target.
+ * For some reason, it doesn't need any specific code and can share the base Session class
+ * aside TabSession.
+ */
+class MainProcessSession extends Session {}
--- a/remote/sessions/Session.jsm
+++ b/remote/sessions/Session.jsm
@@ -7,19 +7,16 @@
 var EXPORTED_SYMBOLS = ["Session"];
 
 const { ParentProcessDomains } = ChromeUtils.import(
   "chrome://remote/content/domains/ParentProcessDomains.jsm"
 );
 const { Domains } = ChromeUtils.import(
   "chrome://remote/content/domains/Domains.jsm"
 );
-const { RemoteAgentError, UnknownMethodError } = ChromeUtils.import(
-  "chrome://remote/content/Error.jsm"
-);
 
 /**
  * A session represents exactly one client WebSocket connection.
  *
  * Every new WebSocket connections is associated with one session that
  * deals with dispatching incoming command requests to the right
  * target, sending back responses, and propagating events originating
  * from domains.
@@ -34,17 +31,17 @@ class Session {
    * @param Connection connection
    *        The connection used to communicate with the server.
    * @param Target target
    *        The target to which this session communicates with.
    * @param Number id (optional)
    *        If this session isn't the default one used for the HTTP endpoint we
    *        connected to, the session requires an id to distinguish it from the default
    *        one. This id is used to filter our request, responses and events between
-   *        all active sessions.
+   *        all active sessions. For now, this is only passed by `Target.attachToTarget()`.
    */
   constructor(connection, target, id) {
     this.connection = connection;
     this.target = target;
     this.id = id;
 
     this.destructor = this.destructor.bind(this);
 
@@ -53,65 +50,24 @@ class Session {
 
     this.domains = new Domains(this, ParentProcessDomains);
   }
 
   destructor() {
     this.domains.clear();
   }
 
-  async onMessage({ id, method, params }) {
-    try {
-      if (typeof id == "undefined") {
-        throw new TypeError("Message missing 'id' field");
-      }
-      if (typeof method == "undefined") {
-        throw new TypeError("Message missing 'method' field");
-      }
-
-      const { domain, command } = Domains.splitMethod(method);
-      await this.execute(id, domain, command, params);
-    } catch (e) {
-      this.onError(id, e);
-    }
-  }
-
-  async execute(id, domain, command, params) {
-    if (!this.domains.domainSupportsMethod(domain, command)) {
-      throw new UnknownMethodError(domain, command);
-    }
-    const inst = this.domains.get(domain);
-    const result = await inst[command](params);
-    this.onResult(id, result);
+  execute(id, domain, command, params) {
+    return this.domains.execute(domain, command, params);
   }
 
-  onResult(id, result) {
-    this.connection.send({
-      id,
-      sessionId: this.id,
-      result,
-    });
-  }
-
-  onError(id, error) {
-    this.connection.send({
-      id,
-      sessionId: this.id,
-      error: {
-        message: RemoteAgentError.format(error, { stack: true }),
-      },
-    });
-  }
-
-  // Domain event listener
-
+  /**
+   * Domains event listener. Called when an event is fired
+   * by any Domain and has to be sent to the client.
+   */
   onEvent(eventName, params) {
-    this.connection.send({
-      sessionId: this.id,
-      method: eventName,
-      params,
-    });
+    this.connection.onEvent(eventName, params, this.id);
   }
 
   toString() {
     return `[object ${this.constructor.name} ${this.connection.id}]`;
   }
 }
--- a/remote/sessions/TabSession.jsm
+++ b/remote/sessions/TabSession.jsm
@@ -1,19 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 var EXPORTED_SYMBOLS = ["TabSession"];
 
-const { Domains } = ChromeUtils.import(
-  "chrome://remote/content/domains/Domains.jsm"
-);
 const { Session } = ChromeUtils.import(
   "chrome://remote/content/sessions/Session.jsm"
 );
 
 /**
  * A session to communicate with a given tab
  */
 class TabSession extends Session {
@@ -22,101 +19,72 @@ class TabSession extends Session {
    *        The connection used to communicate with the server.
    * @param TabTarget target
    *        The tab target to which this session communicates with.
    * @param Number id (optional)
    *        If this session isn't the default one used for the HTTP endpoint we
    *        connected to, the session requires an id to distinguish it from the default
    *        one. This id is used to filter our request, responses and events between
    *        all active sessions.
-   * @param Session parentSession (optional)
-   *        If this isn't the default session, optional hand over a session to which
-   *        we will forward all request responses and events via
-   *        `Target.receivedMessageFromTarget` events.
+   *        For now, this is only passed by `Target.attachToTarget()`.
+   *        Otherwise it will be undefined when you are connecting directly to
+   *        a given Tab. i.e. connect directly to the WebSocket URL provided by
+   *        /json/list HTTP endpoint.
    */
-  constructor(connection, target, id, parentSession) {
+  constructor(connection, target, id) {
     super(connection, target, id);
-    this.parentSession = parentSession;
+
+    // Request id => { resolve, reject }
+    this.requestPromises = new Map();
 
     this.mm.addMessageListener("remote:event", this);
     this.mm.addMessageListener("remote:result", this);
     this.mm.addMessageListener("remote:error", this);
 
     this.mm.loadFrameScript(
       "chrome://remote/content/sessions/frame-script.js",
       false
     );
   }
 
   destructor() {
     super.destructor();
 
+    this.requestPromises.clear();
+
     this.mm.sendAsyncMessage("remote:destroy", {
       browsingContextId: this.browsingContext.id,
     });
 
     this.mm.removeMessageListener("remote:event", this);
     this.mm.removeMessageListener("remote:result", this);
     this.mm.removeMessageListener("remote:error", this);
   }
 
-  async onMessage({ id, method, params }) {
-    try {
-      if (typeof id == "undefined") {
-        throw new TypeError("Message missing 'id' field");
-      }
-      if (typeof method == "undefined") {
-        throw new TypeError("Message missing 'method' field");
-      }
-
-      const { domain, command } = Domains.splitMethod(method);
-      if (this.domains.domainSupportsMethod(domain, command)) {
-        await this.execute(id, domain, command, params);
-      } else {
-        this.executeInChild(id, domain, command, params);
-      }
-    } catch (e) {
-      this.onError(id, e);
+  execute(id, domain, command, params) {
+    // Check if the domain and command is implemented in the parent
+    // and execute it there. Otherwise forward the command to the content process
+    // in order to try to execute it in the content process.
+    if (this.domains.domainSupportsMethod(domain, command)) {
+      return super.execute(id, domain, command, params);
     }
+    return this.executeInChild(id, domain, command, params);
   }
 
   executeInChild(id, domain, command, params) {
-    this.mm.sendAsyncMessage("remote:request", {
-      browsingContextId: this.browsingContext.id,
-      request: { id, domain, command, params },
-    });
-  }
+    return new Promise((resolve, reject) => {
+      // Save the promise's resolution and rejection handler in order to later
+      // resolve this promise once we receive the reply back from the content process.
+      this.requestPromises.set(id, { resolve, reject });
 
-  onResult(id, result) {
-    super.onResult(id, result);
-
-    // When `Target.sendMessageToTarget` is used, we should forward the responses
-    // to the parent session from which we called `sendMessageToTarget`.
-    if (this.parentSession) {
-      this.parentSession.onEvent("Target.receivedMessageFromTarget", {
-        sessionId: this.id,
-        message: JSON.stringify({ id, result }),
+      this.mm.sendAsyncMessage("remote:request", {
+        browsingContextId: this.browsingContext.id,
+        request: { id, domain, command, params },
       });
-    }
-  }
-
-  onEvent(eventName, params) {
-    super.onEvent(eventName, params);
-
-    // When `Target.sendMessageToTarget` is used, we should forward the responses
-    // to the parent session from which we called `sendMessageToTarget`.
-    if (this.parentSession) {
-      this.parentSession.onEvent("Target.receivedMessageFromTarget", {
-        sessionId: this.id,
-        message: JSON.stringify({
-          method: eventName,
-          params,
-        }),
-      });
-    }
+    });
   }
 
   get mm() {
     return this.target.mm;
   }
 
   get browsingContext() {
     return this.target.browsingContext;
@@ -124,21 +92,25 @@ class TabSession extends Session {
 
   // nsIMessageListener
 
   receiveMessage({ name, data }) {
     const { id, result, event, error } = data;
 
     switch (name) {
       case "remote:result":
-        this.onResult(id, result);
+        const { resolve } = this.requestPromises.get(id);
+        resolve(result);
+        this.requestPromises.delete(id);
         break;
 
       case "remote:event":
-        this.onEvent(event.eventName, event.params);
+        this.connection.onEvent(event.eventName, event.params, this.id);
         break;
 
       case "remote:error":
-        this.onError(id, error);
+        const { reject } = this.requestPromises.get(id);
+        reject(error);
+        this.requestPromises.delete(id);
         break;
     }
   }
 }
--- a/remote/targets/MainProcessTarget.jsm
+++ b/remote/targets/MainProcessTarget.jsm
@@ -4,18 +4,18 @@
 
 "use strict";
 
 var EXPORTED_SYMBOLS = ["MainProcessTarget"];
 
 const { Target } = ChromeUtils.import(
   "chrome://remote/content/targets/Target.jsm"
 );
-const { Session } = ChromeUtils.import(
-  "chrome://remote/content/sessions/Session.jsm"
+const { MainProcessSession } = ChromeUtils.import(
+  "chrome://remote/content/sessions/MainProcessSession.jsm"
 );
 const { RemoteAgent } = ChromeUtils.import(
   "chrome://remote/content/RemoteAgent.jsm"
 );
 
 const UUIDGen = Cc["@mozilla.org/uuid-generator;1"].getService(
   Ci.nsIUUIDGenerator
 );
@@ -26,17 +26,17 @@ const UUIDGen = Cc["@mozilla.org/uuid-ge
  * Matches BrowserDevToolsAgentHost from chromium, and only support a couple of Domains:
  * https://cs.chromium.org/chromium/src/content/browser/devtools/browser_devtools_agent_host.cc?dr=CSs&g=0&l=80-91
  */
 class MainProcessTarget extends Target {
   /*
    * @param Targets targets
    */
   constructor(targets) {
-    super(targets, Session);
+    super(targets, MainProcessSession);
 
     this.type = "browser";
     this.id = UUIDGen.generateUUID()
       .toString()
       .slice(1, -1);
 
     // Define the HTTP path to query this target
     this.path = `/devtools/browser/${this.id}`;
--- a/remote/test/browser/browser_session.js
+++ b/remote/test/browser/browser_session.js
@@ -7,17 +7,17 @@ add_task(async function() {
   await RemoteAgent.listen(Services.io.newURI("http://localhost:9222"));
   const CDP = await getCDP();
   const { webSocketDebuggerUrl } = await CDP.Version();
   const client = await CDP({ target: webSocketDebuggerUrl });
 
   try {
     await client.send("Hoobaflooba");
   } catch (e) {
-    ok(e.message.match(/TypeError: Invalid method format/));
+    ok(e.message.match(/Invalid method format/));
   }
   try {
     await client.send("Hooba.flooba");
   } catch (e) {
     ok(e.message.match(/UnknownMethodError/));
   }
 
   await client.close();
--- a/remote/test/browser/browser_target.js
+++ b/remote/test/browser/browser_target.js
@@ -74,26 +74,29 @@ add_task(async function() {
     "attachedToTarget and attachToTarget returns the same session id"
   );
   is(
     attachedEvent.targetInfo.type,
     "page",
     "attachedToTarget creates a tab by default"
   );
 
+  info("Calling Target.sendMessageToTarget");
   const onResponse = Target.receivedMessageFromTarget();
   const id = 1;
   const message = JSON.stringify({
     id,
     method: "Page.navigate",
     params: {
       url: "data:text/html;charset=utf-8,new-page",
     },
   });
   await Target.sendMessageToTarget({ sessionId, message });
+
+  info("Waiting for Target.receivedMessageToTarget");
   const response = await onResponse;
   is(response.sessionId, sessionId, "The response is from the same session");
   const responseMessage = JSON.parse(response.message);
   is(responseMessage.id, id, "The response is from the same session");
   ok(
     !!responseMessage.result.frameId,
     "received the `frameId` out of `Page.navigate` request"
   );
new file mode 100644
--- /dev/null
+++ b/remote/test/unit/test_Connection.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Connection } = ChromeUtils.import(
+  "chrome://remote/content/Connection.jsm"
+);
+
+add_test(function test_Connection_splitMethod() {
+  for (const t of [42, null, true, {}, [], undefined]) {
+    Assert.throws(
+      () => Connection.splitMethod(t),
+      /TypeError/,
+      `${typeof t} throws`
+    );
+  }
+  for (const s of ["", ".", "foo.", ".bar", "foo.bar.baz"]) {
+    Assert.throws(
+      () => Connection.splitMethod(s),
+      /Invalid method format: ".*"/,
+      `"${s}" throws`
+    );
+  }
+  deepEqual(Connection.splitMethod("foo.bar"), {
+    domain: "foo",
+    command: "bar",
+  });
+
+  run_next_test();
+});
--- a/remote/test/unit/test_Domains.js
+++ b/remote/test/unit/test_Domains.js
@@ -120,28 +120,8 @@ add_test(function test_Domains_clear() {
   equal(domains.size, 1);
 
   domains.clear();
   equal(domains.size, 0);
   ok(dtorCalled);
 
   run_next_test();
 });
-
-add_test(function test_Domains_splitMethod() {
-  for (const t of [42, null, true, {}, [], undefined]) {
-    Assert.throws(
-      () => Domains.splitMethod(t),
-      /TypeError/,
-      `${typeof t} throws`
-    );
-  }
-  for (const s of ["", ".", "foo.", ".bar", "foo.bar.baz"]) {
-    Assert.throws(
-      () => Domains.splitMethod(s),
-      /Invalid method format: ".*"/,
-      `"${s}" throws`
-    );
-  }
-  deepEqual(Domains.splitMethod("foo.bar"), { domain: "foo", command: "bar" });
-
-  run_next_test();
-});
--- a/remote/test/unit/xpcshell.ini
+++ b/remote/test/unit/xpcshell.ini
@@ -1,10 +1,11 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 [DEFAULT]
 skip-if = appname == "thunderbird"
 
+[test_Connection.js]
 [test_Domains.js]
 [test_Error.js]
 [test_Session.js]