Bug 1326534 - Rewrite capabilities parsing in Marionette; r=automatedtester
authorAndreas Tolfsen <ato@mozilla.com>
Sat, 31 Dec 2016 12:21:34 +0000
changeset 353418 a77fb55d711d0dfc9df080dcc8a4850986a7cd42
parent 353417 8b2278c947ebfd1b1e9758f4f6351c43bd06476a
child 353419 a5446a3e7e4c245ff9de1c74bb42f6eebb95d2a9
push id6795
push userjlund@mozilla.com
push dateMon, 23 Jan 2017 14:19:46 +0000
treeherdermozilla-esr52@76101b503191 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersautomatedtester
bugs1326534
milestone52.0a2
Bug 1326534 - Rewrite capabilities parsing in Marionette; r=automatedtester This patch provides a (nearly) WebDriver conforming implementation of capabilities in Marionette. The work remaining is pending further clarification in the specification. Capabilities are represented internally as a complex object provided by `session.Capabilities`. Timeouts and proxy configuration are also represented by the similar complex objects `session.Timeouts` and `session.Proxy`, respectively. The capabilities stored in `GeckoDriver#sessionCapabilities` are the result of parsing user-provided desired- and required capabilities. WebDriver now uses `firstMatch` and `alwaysMatch` primitives for capabilities, but as this is considered a wider breaking change, the move to these primitives will be done at a later stage. It’s prudent to point out that the base techniques used with the new primitives are similar to those implemented for `desiredCapabilities` and `requiredCapabilities` in this patch, and that the work needed to adapt them is considered trivial. When capabilities are presented back to the user (the so called processed capabilities), we call the `toJSON` implementation on the complex objects. `session.Capabilities#toJSON` calls the internal function `marshal` which ensures empty fields are dropped. `marshal` can be considered to be a specialisation of the standard library `JSON.stringify`, which also calls `toJSON` on entry values if they provide this function. The changeset overall also provides a much deeper level of testing of WebDriver capabilities. MozReview-Commit-ID: 97xGt3cnMys
testing/marionette/jar.mn
testing/marionette/session.js
testing/marionette/test_session.js
testing/marionette/unit.ini
--- a/testing/marionette/jar.mn
+++ b/testing/marionette/jar.mn
@@ -26,15 +26,16 @@ marionette.jar:
   content/cookies.js (cookies.js)
   content/atom.js (atom.js)
   content/evaluate.js (evaluate.js)
   content/logging.js (logging.js)
   content/navigate.js (navigate.js)
   content/l10n.js (l10n.js)
   content/assert.js (assert.js)
   content/addon.js (addon.js)
+  content/session.js (session.js)
 #ifdef ENABLE_TESTS
   content/test.xul (chrome/test.xul)
   content/test2.xul (chrome/test2.xul)
   content/test_dialog.xul (chrome/test_dialog.xul)
   content/test_nested_iframe.xul (chrome/test_nested_iframe.xul)
   content/test_anonymous_content.xul (chrome/test_anonymous_content.xul)
 #endif
new file mode 100644
--- /dev/null
+++ b/testing/marionette/session.js
@@ -0,0 +1,457 @@
+/* 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 {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+Cu.import("chrome://marionette/content/assert.js");
+Cu.import("chrome://marionette/content/error.js");
+
+this.EXPORTED_SYMBOLS = ["session"];
+
+const logger = Log.repository.getLogger("Marionette");
+const {pprint} = error;
+
+// Enable testing this module, as Services.appinfo.* is not available
+// in xpcshell tests.
+const appinfo = {name: "<missing>", version: "<missing>"};
+try { appinfo.name = Services.appinfo.name.toLowerCase(); } catch (e) {}
+try { appinfo.version = Services.appinfo.version; } catch (e) {}
+
+/** State associated with a WebDriver session. */
+this.session = {};
+
+/** Representation of WebDriver session timeouts. */
+session.Timeouts = class {
+  constructor () {
+    // disabled
+    this.implicit = 0;
+    // five mintues
+    this.pageLoad = 300000;
+    // 30 seconds
+    this.script = 30000;
+  }
+
+  toString () { return "[object session.Timeouts]"; }
+
+  toJSON () {
+    return {
+      "implicit": this.implicit,
+      "page load": this.pageLoad,
+      "script": this.script,
+    };
+  }
+
+  static fromJSON (json) {
+    assert.object(json);
+    let t = new session.Timeouts();
+
+    for (let [typ, ms] of Object.entries(json)) {
+      assert.positiveInteger(ms);
+
+      switch (typ) {
+        case "implicit":
+          t.implicit = ms;
+          break;
+
+        case "script":
+          t.script = ms;
+          break;
+
+        case "page load":
+          t.pageLoad = ms;
+          break;
+
+        default:
+          throw new InvalidArgumentError();
+      }
+    }
+
+    return t;
+  }
+};
+
+/** Enum of page loading strategies. */
+session.PageLoadStrategy = {
+  None: "none",
+  Eager: "eager",
+  Normal: "normal",
+};
+
+/** Proxy configuration object representation. */
+session.Proxy = class {
+  constructor() {
+    this.proxyType = null;
+    this.httpProxy = null;
+    this.httpProxyPort = null;
+    this.sslProxy = null;
+    this.sslProxyPort = null;
+    this.ftpProxy = null;
+    this.ftpProxyPort = null;
+    this.socksProxy = null;
+    this.socksProxyPort = null;
+    this.socksVersion = null;
+    this.proxyAutoconfigUrl = null;
+  }
+
+  /**
+   * Sets Firefox proxy settings.
+   *
+   * @return {boolean}
+   *     True if proxy settings were updated as a result of calling this
+   *     function, or false indicating that this function acted as
+   *     a no-op.
+   */
+  init() {
+    switch (this.proxyType) {
+      case "manual":
+        Preferences.set("network.proxy.type", 1);
+        if (this.httpProxy && this.httpProxyPort) {
+          Preferences.set("network.proxy.http", this.httpProxy);
+          Preferences.set("network.proxy.http_port", this.httpProxyPort);
+        }
+        if (this.sslProxy && this.sslProxyPort) {
+          Preferences.set("network.proxy.ssl", this.sslProxy);
+          Preferences.set("network.proxy.ssl_port", this.sslProxyPort);
+        }
+        if (this.ftpProxy && this.ftpProxyPort) {
+          Preferences.set("network.proxy.ftp", this.ftpProxy);
+          Preferences.set("network.proxy.ftp_port", this.ftpProxyPort);
+        }
+        if (this.socksProxy) {
+          Preferences.set("network.proxy.socks", this.socksProxy);
+          Preferences.set("network.proxy.socks_port", this.socksProxyPort);
+          if (this.socksVersion) {
+            Preferences.set("network.proxy.socks_version", this.socksVersion);
+          }
+        }
+        return true;
+
+      case "pac":
+        Preferences.set("network.proxy.type", 2);
+        Preferences.set("network.proxy.autoconfig_url", this.proxyAutoconfigUrl);
+        return true;
+
+      case "autodetect":
+        Preferences.set("network.proxy.type", 4);
+        return true;
+
+      case "system":
+        Preferences.set("network.proxy.type", 5);
+        return true;
+
+      case "noproxy":
+        Preferences.set("network.proxy.type", 0);
+        return true;
+
+      default:
+        return false;
+    }
+  }
+
+  toString () { return "[object session.Proxy]"; }
+
+  toJSON () {
+    return marshal({
+      proxyType: this.proxyType,
+      httpProxy: this.httpProxy,
+      httpProxyPort: this.httpProxyPort ,
+      sslProxy: this.sslProxy,
+      sslProxyPort: this.sslProxyPort,
+      ftpProxy: this.ftpProxy,
+      ftpProxyPort: this.ftpProxyPort,
+      socksProxy: this.socksProxy,
+      socksProxyPort: this.socksProxyPort,
+      socksProxyVersion: this.socksProxyVersion,
+      proxyAutoconfigUrl: this.proxyAutoconfigUrl,
+    });
+  }
+
+  static fromJSON (json) {
+    let p = new session.Proxy();
+    if (typeof json == "undefined" || json === null) {
+      return p;
+    }
+
+    assert.object(json);
+
+    assert.in("proxyType", json);
+    p.proxyType = json.proxyType;
+
+    if (json.proxyType == "manual") {
+      if (typeof json.httpProxy != "undefined") {
+        p.httpProxy = assert.string(json.httpProxy);
+        p.httpProxyPort = assert.positiveInteger(json.httpProxyPort);
+      }
+
+      if (typeof json.sslProxy != "undefined") {
+        p.sslProxy = assert.string(json.sslProxy);
+        p.sslProxyPort = assert.positiveInteger(json.sslProxyPort);
+      }
+
+      if (typeof json.ftpProxy != "undefined") {
+        p.ftpProxy = assert.string(json.ftpProxy);
+        p.ftpProxyPort = assert.positiveInteger(json.ftpProxyPort);
+      }
+
+      if (typeof json.socksProxy != "undefined") {
+        p.socksProxy = assert.string(json.socksProxy);
+        p.socksProxyPort = assert.positiveInteger(json.socksProxyPort);
+        p.socksProxyVersion = assert.positiveInteger(json.socksProxyVersion);
+      }
+    }
+
+    if (typeof json.proxyAutoconfigUrl != "undefined") {
+      p.proxyAutoconfigUrl = assert.string(json.proxyAutoconfigUrl);
+    }
+
+    return p;
+  }
+};
+
+/** WebDriver session capabilities representation. */
+session.Capabilities = class extends Map {
+  constructor () {
+    super([
+      // webdriver
+      ["browserName", appinfo.name],
+      ["browserVersion", appinfo.version],
+      ["platformName", Services.sysinfo.getProperty("name").toLowerCase()],
+      ["platformVersion", Services.sysinfo.getProperty("version")],
+      ["pageLoadStrategy", session.PageLoadStrategy.Normal],
+      ["acceptInsecureCerts", false],
+      ["timeouts", new session.Timeouts()],
+      ["proxy", new session.Proxy()],
+
+      // features
+      ["rotatable", appinfo.name == "B2G"],
+
+      // proprietary
+      ["specificationLevel", 0],
+      ["moz:processID", Services.appinfo.processID],
+      ["moz:profile", maybeProfile()],
+      ["moz:accessibilityChecks", false],
+    ]);
+  }
+
+  set (key, value) {
+    if (key === "timeouts" && !(value instanceof session.Timeouts)) {
+      throw new TypeError();
+    } else if (key === "proxy" && !(value instanceof session.Proxy)) {
+      throw new TypeError();
+    }
+
+    return super.set(key, value);  
+  }
+
+  toString() { return "[object session.Capabilities]"; }
+
+  toJSON() {
+    return marshal(this);
+  }
+
+  /**
+   * Unmarshal a JSON object representation of WebDriver capabilities.
+   *
+   * @param {Object.<string, ?>=} json
+   *     WebDriver capabilities.
+   * @param {boolean=} merge
+   *     If providing |json| with |desiredCapabilities| or
+   *     |requiredCapabilities| fields, or both, it should be set to
+   *     true to merge these before parsing.  This indicates
+   *     that the input provided is from a client and not from
+   *     |session.Capabilities#toJSON|.
+   *
+   * @return {session.Capabilities}
+   *     Internal representation of WebDriver capabilities.
+   */
+  static fromJSON (json, {merge = false} = {}) {
+    if (typeof json == "undefined" || json === null) {
+      json = {};
+    }
+    assert.object(json);
+
+    if (merge) {
+      json = session.Capabilities.merge_(json);
+    }
+    return session.Capabilities.match_(json);
+  }
+
+  // Processes capabilities as described by WebDriver.
+  static merge_ (json) {
+    for (let entry of [json.desiredCapabilities, json.requiredCapabilities]) {
+      if (typeof entry == "undefined" || entry === null) {
+        continue;
+      }
+      assert.object(entry, error.pprint`Expected ${entry} to be a capabilities object`);
+    }
+
+    let desired = json.desiredCapabilities || {};
+    let required = json.requiredCapabilities || {};
+
+    // One level deep union merge of desired- and required capabilities
+    // with preference on required
+    return Object.assign({}, desired, required);
+  }
+
+  // Matches capabilities as described by WebDriver.
+  static match_ (caps = {}) {
+    let matched = new session.Capabilities();
+
+    const defined = v => typeof v != "undefined" && v !== null;
+    const wildcard = v => v === "*";
+
+    // Iff |actual| provides some value, or is a wildcard or an exact
+    // match of |expected|.  This means it can be null or undefined,
+    // or "*", or "firefox".
+    function stringMatch (actual, expected) {
+      return !defined(actual) || (wildcard(actual) || actual === expected);
+    }
+
+    for (let [k,v] of Object.entries(caps)) {
+      switch (k) {
+        case "browserName":
+          let bname = matched.get("browserName");
+          if (!stringMatch(v, bname)) {
+            throw new TypeError(
+                pprint`Given browserName ${v}, but my name is ${bname}`);
+          }
+          break;
+
+        // TODO(ato): bug 1326397
+        case "browserVersion":
+          let bversion = matched.get("browserVersion");
+          if (!stringMatch(v, bversion)) {
+            throw new TypeError(
+                pprint`Given browserVersion ${v}, ` +
+                pprint`but current version is ${bversion}`);
+          }
+          break;
+
+        case "platformName":
+          let pname = matched.get("platformName");
+          if (!stringMatch(v, pname)) {
+            throw new TypeError(
+                pprint`Given platformName ${v}, ` +
+                pprint`but current platform is ${pname}`);
+          }
+          break;
+
+        // TODO(ato): bug 1326397
+        case "platformVersion":
+          let pversion = matched.get("platformVersion");
+          if (!stringMatch(v, pversion)) {
+            throw new TypeError(
+                pprint`Given platformVersion ${v}, ` +
+                pprint`but current platform version is ${pversion}`);
+          }
+          break;
+
+        case "acceptInsecureCerts":
+          assert.boolean(v);
+          matched.set("acceptInsecureCerts", v);
+          break;
+
+        case "pageLoadStrategy":
+          if (Object.values(session.PageLoadStrategy).includes(v)) {
+            matched.set("pageLoadStrategy", v);
+          } else {
+            throw new TypeError("Unknown page load strategy: " + v);
+          }
+          break;
+
+        case "proxy":
+          let proxy = session.Proxy.fromJSON(v);
+          matched.set("proxy", proxy);
+          break;
+
+        case "timeouts":
+          let timeouts = session.Timeouts.fromJSON(v);
+          matched.set("timeouts", timeouts);
+          break;
+
+        case "specificationLevel":
+          assert.positiveInteger(v);
+          matched.set("specificationLevel", v);
+          break;
+
+        case "moz:accessibilityChecks":
+          assert.boolean(v);
+          matched.set("moz:accessibilityChecks", v);
+          break;
+      }
+    }
+
+    return matched;
+  }
+};
+
+// Specialisation of |JSON.stringify| that produces JSON-safe object
+// literals, dropping empty objects and entries which values are undefined
+// or null.  Objects are allowed to produce their own JSON representations
+// by implementing a |toJSON| function.
+function marshal(obj) {
+  let rv = Object.create(null);
+
+  function* iter(mapOrObject) {
+    if (mapOrObject instanceof Map) {
+      for (const [k,v] of mapOrObject) {
+        yield [k,v];
+      }
+    } else {
+      for (const k of Object.keys(mapOrObject)) {
+        yield [k, mapOrObject[k]];
+      }
+    }
+  }
+
+  for (let [k,v] of iter(obj)) {
+    // Skip empty values when serialising to JSON.
+    if (typeof v == "undefined" || v === null) {
+      continue;
+    }
+
+    // Recursively marshal objects that are able to produce their own
+    // JSON representation.
+    if (typeof v.toJSON == "function") {
+      v = marshal(v.toJSON());
+    }
+
+    // Or do the same for object literals.
+    else if (isObject(v)) {
+      v = marshal(v);
+    }
+
+    // And finally drop (possibly marshaled) objects which have no
+    // entries.
+    if (!isObjectEmpty(v)) {
+      rv[k] = v;
+    }
+  }
+
+  return rv;
+}
+
+function isObject(obj) {
+  return Object.prototype.toString.call(obj) == "[object Object]";
+}
+
+function isObjectEmpty(obj) {
+  return isObject(obj) && Object.keys(obj).length === 0;
+}
+
+// Services.dirsvc is not accessible from content frame scripts,
+// but we should not panic about that.
+function maybeProfile() {
+  try {
+    return Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+  } catch (e) {
+    return "<protected>";
+  }
+}
new file mode 100644
--- /dev/null
+++ b/testing/marionette/test_session.js
@@ -0,0 +1,370 @@
+/* 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 {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+Cu.import("chrome://marionette/content/error.js");
+Cu.import("chrome://marionette/content/session.js");
+
+add_test(function test_Timeouts_ctor() {
+  let ts = new session.Timeouts();
+  equal(ts.implicit, 0);
+  equal(ts.pageLoad, 300000);
+  equal(ts.script, 30000);
+
+  run_next_test();
+});
+
+add_test(function test_Timeouts_toString() {
+  equal(new session.Timeouts().toString(), "[object session.Timeouts]");
+
+  run_next_test();
+});
+
+add_test(function test_Timeouts_toJSON() {
+  let ts = new session.Timeouts();
+  deepEqual(ts.toJSON(), {"implicit": 0, "page load": 300000, "script": 30000});
+
+  run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON() {
+  let json = {
+    "implicit": 10,
+    "page load": 20,
+    "script": 30,
+  };
+  let ts = session.Timeouts.fromJSON(json);
+  equal(ts.implicit, json["implicit"]);
+  equal(ts.pageLoad, json["page load"]);
+  equal(ts.script, json["script"]);
+
+  run_next_test();
+});
+
+add_test(function test_PageLoadStrategy() {
+  equal(session.PageLoadStrategy.None, "none");
+  equal(session.PageLoadStrategy.Eager, "eager");
+  equal(session.PageLoadStrategy.Normal, "normal");
+
+  run_next_test();
+});
+
+add_test(function test_Proxy_ctor() {
+  let p = new session.Proxy();
+  let props = [
+    "proxyType",
+    "httpProxy",
+    "httpProxyPort",
+    "sslProxy",
+    "sslProxyPort",
+    "ftpProxy",
+    "ftpProxyPort",
+    "socksProxy",
+    "socksProxyPort",
+    "socksVersion",
+    "proxyAutoconfigUrl",
+  ];
+  for (let prop of props) {
+    ok(prop in p, `${prop} in ${JSON.stringify(props)}`);
+    equal(p[prop], null);
+  }
+
+  run_next_test();
+});
+
+add_test(function test_Proxy_init() {
+  let p = new session.Proxy();
+
+  // no changed made, and 5 (system) is default
+  equal(p.init(), false);
+  equal(Preferences.get("network.proxy.type"), 5);
+
+  // pac
+  p.proxyType = "pac";
+  p.proxyAutoconfigUrl = "http://localhost:1234";
+  ok(p.init());
+
+  equal(Preferences.get("network.proxy.type"), 2);
+  equal(Preferences.get("network.proxy.autoconfig_url"),
+      "http://localhost:1234");
+
+  // autodetect
+  p = new session.Proxy();
+  p.proxyType = "autodetect";
+  ok(p.init());
+  equal(Preferences.get("network.proxy.type"), 4);
+
+  // system
+  p = new session.Proxy();
+  p.proxyType = "system";
+  ok(p.init());
+  equal(Preferences.get("network.proxy.type"), 5);
+
+  // noproxy
+  p = new session.Proxy();
+  p.proxyType = "noproxy";
+  ok(p.init());
+  equal(Preferences.get("network.proxy.type"), 0);
+
+  run_next_test();
+});
+
+add_test(function test_Proxy_toString() {
+  equal(new session.Proxy().toString(), "[object session.Proxy]");
+
+  run_next_test();
+});
+
+add_test(function test_Proxy_toJSON() {
+  let p = new session.Proxy();
+  deepEqual(p.toJSON(), {});
+
+  p = new session.Proxy();
+  p.proxyType = "manual";
+  deepEqual(p.toJSON(), {proxyType: "manual"});
+
+  run_next_test();
+});
+
+add_test(function test_Proxy_fromJSON() {
+  deepEqual({}, session.Proxy.fromJSON(undefined).toJSON());
+  deepEqual({}, session.Proxy.fromJSON(null).toJSON());
+
+  for (let typ of [true, 42, "foo", []]) {
+    Assert.throws(() => session.Proxy.fromJSON(typ), InvalidArgumentError);
+  }
+
+  // must contain proxyType
+  Assert.throws(() => session.Proxy.fromJSON({}), InvalidArgumentError);
+  deepEqual({proxyType: "foo"},
+      session.Proxy.fromJSON({proxyType: "foo"}).toJSON());
+
+  // manual
+  session.Proxy.fromJSON({proxyType: "manual"});
+
+  for (let proxy of ["httpProxy", "sslProxy", "ftpProxy", "socksProxy"]) {
+    let manual = {proxyType: "manual"};
+
+    for (let typ of [true, 42, [], {}, null]) {
+      manual[proxy] = typ;
+      Assert.throws(() => session.Proxy.fromJSON(manual),
+          InvalidArgumentError);
+    }
+
+    manual[proxy] = "foo";
+    Assert.throws(() => session.Proxy.fromJSON(manual),
+        InvalidArgumentError);
+
+    for (let typ of ["bar", true, [], {}, null, undefined]) {
+      manual[proxy + "Port"] = typ;
+      Assert.throws(() => session.Proxy.fromJSON(manual),
+          InvalidArgumentError);
+    }
+
+    manual[proxy] = "foo";
+    manual[proxy + "Port"] = 1234;
+
+    let expected = {
+      "proxyType": "manual",
+      [proxy]: "foo",
+      [proxy + "Port"]: 1234,
+    };
+
+    if (proxy == "socksProxy") {
+      manual.socksProxyVersion = 42;
+      expected.socksProxyVersion = 42;
+    }
+    deepEqual(expected, session.Proxy.fromJSON(manual).toJSON());
+  }
+
+  Assert.throws(() => session.Proxy.fromJSON(
+      {proxyType: "manual", socksProxy: "foo", socksProxyPort: 1234}),
+      InvalidArgumentError);
+
+  run_next_test();
+});
+
+add_test(function test_Capabilities_ctor() {
+  let caps = new session.Capabilities();
+  ok(caps.has("browserName"));
+  ok(caps.has("browserVersion"));
+  ok(caps.has("platformName"));
+  ok(caps.has("platformVersion"));
+  equal(session.PageLoadStrategy.Normal, caps.get("pageLoadStrategy"));
+  equal(false, caps.get("acceptInsecureCerts"));
+  ok(caps.get("timeouts") instanceof session.Timeouts);
+  ok(caps.get("proxy") instanceof session.Proxy);
+
+  ok(caps.has("rotatable"));
+
+  equal(0, caps.get("specificationLevel"));
+  ok(caps.has("moz:processID"));
+  ok(caps.has("moz:profile"));
+  equal(false, caps.get("moz:accessibilityChecks"));
+
+  run_next_test();
+});
+
+add_test(function test_Capabilities_toString() {
+  equal("[object session.Capabilities]", new session.Capabilities().toString());
+
+  run_next_test();
+});
+
+add_test(function test_Capabilities_toJSON() {
+  let caps = new session.Capabilities();
+  let json = caps.toJSON();
+
+  equal(caps.get("browserName"), json.browserName);
+  equal(caps.get("browserVersion"), json.browserVersion);
+  equal(caps.get("platformName"), json.platformName);
+  equal(caps.get("platformVersion"), json.platformVersion);
+  equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy);
+  equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts);
+  deepEqual(caps.get("timeouts").toJSON(), json.timeouts);
+  equal(undefined, json.proxy);
+
+  equal(caps.get("rotatable"), json.rotatable);
+
+  equal(caps.get("specificationLevel"), json.specificationLevel);
+  equal(caps.get("moz:processID"), json["moz:processID"]);
+  equal(caps.get("moz:profile"), json["moz:profile"]);
+  equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]);
+
+  run_next_test();
+});
+
+add_test(function test_Capabilities_fromJSON() {
+  const {fromJSON} = session.Capabilities;
+
+  // plain
+  for (let typ of [{}, null, undefined]) {
+    ok(fromJSON(typ, {merge: true}).has("browserName"));
+    ok(fromJSON(typ, {merge: false}).has("browserName"));
+  }
+  for (let typ of [true, 42, "foo", []]) {
+    Assert.throws(() =>
+        fromJSON(typ, {merge: true}), InvalidArgumentError);
+    Assert.throws(() =>
+        fromJSON(typ, {merge: false}), InvalidArgumentError);
+  }
+
+  // merging
+  let desired = {"moz:accessibilityChecks": false};
+  let required = {"moz:accessibilityChecks": true};
+  let matched = fromJSON(
+      {desiredCapabilities: desired, requiredCapabilities: required},
+      {merge: true});
+  ok(matched.has("moz:accessibilityChecks"));
+  equal(true, matched.get("moz:accessibilityChecks"));
+
+  // desiredCapabilities/requriedCapabilities types
+  for (let typ of [undefined, null, {}]) {
+    ok(fromJSON({desiredCapabilities: typ}, {merge: true}));
+    ok(fromJSON({requiredCapabilities: typ}, {merge: true}));
+  }
+  for (let typ of [true, 42, "foo", []]) {
+    Assert.throws(() => fromJSON({desiredCapabilities: typ}, {merge: true}));
+    Assert.throws(() => fromJSON({requiredCapabilities: typ}, {merge: true}));
+  }
+
+  // matching
+  let caps = new session.Capabilities();
+
+  ok(fromJSON({browserName: caps.get("browserName")}));
+  ok(fromJSON({browserName: null}));
+  ok(fromJSON({browserName: undefined}));
+  ok(fromJSON({browserName: "*"}));
+  Assert.throws(() => fromJSON({browserName: "foo"}));
+
+  ok(fromJSON({browserVersion: caps.get("browserVersion")}));
+  ok(fromJSON({browserVersion: null}));
+  ok(fromJSON({browserVersion: undefined}));
+  ok(fromJSON({browserVersion: "*"}));
+  Assert.throws(() => fromJSON({browserVersion: "foo"}));
+
+  ok(fromJSON({platformName: caps.get("platformName")}));
+  ok(fromJSON({platformName: null}));
+  ok(fromJSON({platformName: undefined}));
+  ok(fromJSON({platformName: "*"}));
+  Assert.throws(() => fromJSON({platformName: "foo"}));
+
+  ok(fromJSON({platformVersion: caps.get("platformVersion")}));
+  ok(fromJSON({platformVersion: null}));
+  ok(fromJSON({platformVersion: undefined}));
+  ok(fromJSON({platformVersion: "*"}));
+  Assert.throws(() => fromJSON({platformVersion: "foo"}));
+
+  caps = fromJSON({acceptInsecureCerts: true});
+  equal(true, caps.get("acceptInsecureCerts"));
+  caps = fromJSON({acceptInsecureCerts: false});
+  equal(false, caps.get("acceptInsecureCerts"));
+  Assert.throws(() => fromJSON({acceptInsecureCerts: "foo"}));
+
+  for (let strategy of Object.values(session.PageLoadStrategy)) {
+    caps = fromJSON({pageLoadStrategy: strategy});
+    equal(strategy, caps.get("pageLoadStrategy"));
+  }
+  Assert.throws(() => fromJSON({pageLoadStrategy: "foo"}));
+
+  let proxyConfig = {proxyType: "manual"};
+  caps = fromJSON({proxy: proxyConfig});
+  equal("manual", caps.get("proxy").proxyType);
+
+  let timeoutsConfig = {implicit: 123};
+  caps = fromJSON({timeouts: timeoutsConfig});
+  equal(123, caps.get("timeouts").implicit);
+
+  equal(0, caps.get("specificationLevel"));
+  caps = fromJSON({specificationLevel: 123});
+  equal(123, caps.get("specificationLevel"));
+  Assert.throws(() => fromJSON({specificationLevel: "foo"}));
+  Assert.throws(() => fromJSON({specificationLevel: -1}));
+
+  caps = fromJSON({"moz:accessibilityChecks": true});
+  equal(true, caps.get("moz:accessibilityChecks"));
+  caps = fromJSON({"moz:accessibilityChecks": false});
+  equal(false, caps.get("moz:accessibilityChecks"));
+  Assert.throws(() => fromJSON({"moz:accessibilityChecks": "foo"}));
+
+  run_next_test();
+});
+
+// use session.Proxy.toJSON to test marshal
+add_test(function test_marshal() {
+  let proxy = new session.Proxy();
+
+  // drop empty fields
+  deepEqual({}, proxy.toJSON());
+  proxy.proxyType = "manual";
+  deepEqual({proxyType: "manual"}, proxy.toJSON());
+  proxy.proxyType = null;
+  deepEqual({}, proxy.toJSON());
+  proxy.proxyType = undefined;
+  deepEqual({}, proxy.toJSON());
+
+  // iterate over object literals
+  proxy.proxyType = {foo: "bar"};
+  deepEqual({proxyType: {foo: "bar"}}, proxy.toJSON());
+
+  // iterate over complex object that implement toJSON
+  proxy.proxyType = new session.Proxy();
+  deepEqual({}, proxy.toJSON());
+  proxy.proxyType.proxyType = "manual";
+  deepEqual({proxyType: {proxyType: "manual"}}, proxy.toJSON());
+
+  // drop objects with no entries
+  proxy.proxyType = {foo: {}};
+  deepEqual({}, proxy.toJSON());
+  proxy.proxyType = {foo: new session.Proxy()};
+  deepEqual({}, proxy.toJSON());
+
+  run_next_test();
+});
--- a/testing/marionette/unit.ini
+++ b/testing/marionette/unit.ini
@@ -8,8 +8,9 @@
 skip-if = appname == "thunderbird"
 
 [test_action.js]
 [test_assert.js]
 [test_element.js]
 [test_error.js]
 [test_message.js]
 [test_navigate.js]
+[test_session.js]