Bug 1479127 - Add featuregate library r=mossop,firefox-build-system-reviewers,mshal
authorMike Cooper <mcooper@mozilla.com>
Wed, 09 Jan 2019 20:01:52 +0000
changeset 510245 badd599eb7d785f651f69a4567c5d6b855d44ce7
parent 510244 f184da8d467cc98ff07dfbf880557eac00566c57
child 510246 741ded5432288db8ea024097cd3e597ddde895fa
push id10547
push userffxbld-merge
push dateMon, 21 Jan 2019 13:03:58 +0000
treeherdermozilla-beta@24ec1916bffe [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop, firefox-build-system-reviewers, mshal
bugs1479127
milestone66.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 1479127 - Add featuregate library r=mossop,firefox-build-system-reviewers,mshal Differential Revision: https://phabricator.services.mozilla.com/D5175
browser/base/content/test/static/browser_all_files_referenced.js
toolkit/components/featuregates/FeatureGate.jsm
toolkit/components/featuregates/FeatureGateImplementation.jsm
toolkit/components/featuregates/Features.toml
toolkit/components/featuregates/docs/index.rst
toolkit/components/featuregates/gen_feature_definitions.py
toolkit/components/featuregates/jar.mn
toolkit/components/featuregates/moz.build
toolkit/components/featuregates/test/python/data/empty_feature.toml
toolkit/components/featuregates/test/python/data/good.toml
toolkit/components/featuregates/test/python/data/invalid_toml.toml
toolkit/components/featuregates/test/python/python.ini
toolkit/components/featuregates/test/python/test_gen_feature_definitions.py
toolkit/components/featuregates/test/unit/.eslintrc.js
toolkit/components/featuregates/test/unit/head.js
toolkit/components/featuregates/test/unit/test_FeatureGate.js
toolkit/components/featuregates/test/unit/test_FeatureGateImplementation.js
toolkit/components/featuregates/test/unit/xpcshell.ini
toolkit/components/moz.build
tools/docs/conf.py
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -172,16 +172,20 @@ var whitelist = [
   // find the references)
   {file: "chrome://devtools/skin/images/aboutdebugging-firefox-aurora.svg",
    isFromDevTools: true},
   {file: "chrome://devtools/skin/images/aboutdebugging-firefox-beta.svg",
    isFromDevTools: true},
   {file: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg",
    isFromDevTools: true},
   {file: "chrome://devtools/skin/images/next.svg", isFromDevTools: true},
+   // Feature gates are available but not used yet - Bug 1479127
+   {file: "resource://gre-resources/featuregates/FeatureGate.jsm"},
+   {file: "resource://gre-resources/featuregates/FeatureGateImplementation.jsm"},
+   {file: "resource://gre-resources/featuregates/feature_definitions.json"},
 ];
 
 whitelist = new Set(whitelist.filter(item =>
   ("isFromDevTools" in item) == isDevtools &&
   (!item.skipUnofficial || !AppConstants.MOZILLA_OFFICIAL) &&
   (!item.platforms || item.platforms.includes(AppConstants.platform))
 ).map(item => item.file));
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/FeatureGate.jsm
@@ -0,0 +1,186 @@
+/* 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";
+
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm");
+ChromeUtils.defineModuleGetter(this, "FeatureGateImplementation", "resource://featuregates/FeatureGateImplementation.jsm");
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
+
+var EXPORTED_SYMBOLS = ["FeatureGate"];
+
+XPCOMUtils.defineLazyGetter(this, "gFeatureDefinitionsPromise", async () => {
+  const url = "resource://featuregates/feature_definitions.json";
+  return fetchFeatureDefinitions(url);
+});
+
+const kTargetFacts = new Map([
+  ["release", AppConstants.MOZ_UPDATE_CHANNEL === "release"],
+  ["beta", AppConstants.MOZ_UPDATE_CHANNEL === "beta"],
+  ["dev-edition", AppConstants.MOZ_UPDATE_CHANNEL === "aurora"],
+  ["nightly", AppConstants.MOZ_UPDATE_CHANNEL === "nightly"],
+  ["win", AppConstants.platform === "win"],
+  ["mac", AppConstants.platform === "macosx"],
+  ["linux", AppConstants.platform === "linux"],
+  ["android", AppConstants.platform === "android"],
+]);
+
+async function fetchFeatureDefinitions(url) {
+  const res = await fetch(url);
+  let definitionsJson = await res.json();
+  return new Map(Object.entries(definitionsJson));
+}
+
+/**
+ * Take a map of conditions to values, and return the value who's conditions
+ * match this browser, or the default value in the map.
+ *
+ * @example `evaluateTargetedValue({default: false, nightly: true})` would
+ *          return true on Nightly, and false otherwise.
+ * @param {Object} targetedValue An object mapping string conditions to values. The
+ *                 conditions are comma separated values such as those specified
+ *                 in `kTargetFacts` above. A condition "default" is required, as
+ *                 the fallback valued.
+ * @param {Map} targetingFacts A map of target facts to use, such as `kTargetFacts`.
+ * @returns A value from `targetedValue`.
+ */
+function evaluateTargetedValue(targetedValue, targetingFacts) {
+  if (!Object.hasOwnProperty.call(targetedValue, "default")) {
+    throw new Error(
+      `Targeted value ${JSON.stringify(targetedValue)} has no default key`
+    );
+  }
+
+  for (const [key, value] of Object.entries(targetedValue)) {
+    if (key === "default") {
+      continue;
+    }
+    if (key.split(",").every(part => targetingFacts.get(part))) {
+      return value;
+    }
+  }
+
+  return targetedValue.default;
+}
+
+const kFeatureGateCache = new Map();
+
+/** A high level control for turning features on and off. */
+class FeatureGate {
+  /*
+   * This is structured as a class with static methods to that sphinx-js can
+   * easily document it. This constructor is required for sphinx-js to detect
+   * this class for documentation.
+   */
+
+  constructor() {}
+
+  /**
+   * Constructs a feature gate object that is defined in ``Features.toml``.
+   * This is the primary way to create a ``FeatureGate``.
+   *
+   * @param {string} id The ID of the feature's definition in `Features.toml`.
+   * @param {string} testDefinitionsUrl A URL from which definitions can be fetched. Only use this in tests.
+   * @throws If the ``id`` passed is not defined in ``Features.toml``.
+   */
+  static async fromId(id, testDefinitionsUrl = undefined) {
+    let featureDefinitions;
+    if (testDefinitionsUrl) {
+      featureDefinitions = await fetchFeatureDefinitions(testDefinitionsUrl);
+    } else {
+      featureDefinitions = await gFeatureDefinitionsPromise;
+    }
+
+    if (!featureDefinitions.has(id)) {
+      throw new Error(
+        `Unknown feature id ${id}. Features must be defined in toolkit/components/featuregates/Features.toml`
+      );
+    }
+
+    const definition = featureDefinitions.get(id);
+    const targetValueKeys = ["defaultValue", "isPublic"];
+    for (const key of targetValueKeys) {
+      definition[key] = evaluateTargetedValue(definition[key], kTargetFacts);
+    }
+    return new FeatureGateImplementation(definition);
+  }
+
+  /**
+   * Add an observer for a feature gate by ID. If the feature is of type
+   * boolean and currently enabled, `onEnable` will be called.
+   *
+   * The underlying feature gate instance will be shared with all other callers
+   * of this function, and share an observer.
+   *
+   * @param {string} id The ID of the feature's definition in `Features.toml`.
+   * @param {object} observer Functions to be called when the feature changes.
+   *        All observer functions are optional.
+   * @param {Function()} [observer.onEnable] Called when the feature becomes enabled.
+   * @param {Function()} [observer.onDisable] Called when the feature becomes disabled.
+   * @param {Function(newValue)} [observer.onChange] Called when the
+   *        feature's state changes to any value. The new value will be passed to the
+   *        function.
+   * @param {string} testDefinitionsUrl A URL from which definitions can be fetched. Only use this in tests.
+   * @returns {Promise<boolean>} The current value of the feature.
+   */
+  static async addObserver(id, observer, testDefinitionsUrl = undefined) {
+    if (!kFeatureGateCache.has(id)) {
+      kFeatureGateCache.set(id, await FeatureGate.fromId(id, testDefinitionsUrl));
+    }
+    const feature = kFeatureGateCache.get(id);
+    return feature.addObserver(observer);
+  }
+
+  /**
+   * Remove an observer of changes from this feature
+   * @param {string} id The ID of the feature's definition in `Features.toml`.
+   * @param observer Then observer that was passed to addObserver to remove.
+   */
+  static async removeObserver(id, observer) {
+    let feature = kFeatureGateCache.get(id);
+    if (!feature) {
+      return;
+    }
+    feature.removeObserver(observer);
+    if (feature._observers.size === 0) {
+      kFeatureGateCache.delete(id);
+    }
+  }
+
+  /**
+   * Get the current value of this feature gate. Implementors should avoid
+   * storing the result to avoid missing changes to the feature's value.
+   * Consider using :func:`addObserver` if it is necessary to store the value
+   * of the feature.
+   *
+   * @async
+   * @param {string} id The ID of the feature's definition in `Features.toml`.
+   * @returns {Promise<boolean>} A promise for the value associated with this feature.
+   */
+  static async getValue(id, testDefinitionsUrl = undefined) {
+    let feature = kFeatureGateCache.get(id);
+    if (!feature) {
+      feature = await FeatureGate.fromId(id, testDefinitionsUrl);
+    }
+    return feature.getValue();
+  }
+
+  /**
+   * An alias of `getValue` for boolean typed feature gates.
+   *
+   * @async
+   * @param {string} id The ID of the feature's definition in `Features.toml`.
+   * @returns {Promise<boolean>} A promise for the value associated with this feature.
+   * @throws {Error} If the feature is not a boolean.
+   */
+  static async isEnabled(id, testDefinitionsUrl = undefined) {
+    let feature = kFeatureGateCache.get(id);
+    if (!feature) {
+      feature = await FeatureGate.fromId(id, testDefinitionsUrl);
+    }
+    return feature.isEnabled();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/FeatureGateImplementation.jsm
@@ -0,0 +1,248 @@
+/* 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";
+
+ChromeUtils.defineModuleGetter(this, "Services", "resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["FeatureGateImplementation"];
+
+/** An individual feature gate that can be re-used for more advanced usage. */
+class FeatureGateImplementation {
+  // Note that the following comment is *not* a jsdoc. Making it a jsdoc would
+  // makes sphinx-js expose it to users. This feature shouldn't be used by
+  // users, and so should not be in the docs. Sphinx-js does not respect the
+  // @private marker on a constructor (https://github.com/erikrose/sphinx-js/issues/71).
+  /*
+   * This constructor should only be used directly in tests.
+   * ``FeatureGate.fromId`` should be used instead for most cases.
+   *
+   * @private
+   *
+   * @param {object} definition Description of the feature gate.
+   * @param {string} definition.id
+   * @param {string} definition.title
+   * @param {string} definition.description
+   * @param {boolean} definition.restartRequired
+   * @param {string} definition.type
+   * @param {string} definition.preference
+   * @param {string} definition.defaultValue
+   * @param {object} definition.isPublic
+   * @param {object} definition.bugNumbers
+   */
+  constructor(definition) {
+    this._definition = definition;
+    this._observers = new Set();
+
+    switch (this.type) {
+      case "boolean": {
+        Services.prefs.getDefaultBranch("").setBoolPref(this.preference, this.defaultValue);
+        break;
+      }
+      default: {
+        throw new Error(`Unsupported feature gate type ${this.type}`);
+      }
+    }
+  }
+
+  // The below are all getters instead of direct access to make it easy to provide JSDocs.
+
+  /**
+   * A short string used to refer to this feature in code.
+   * @type string
+   */
+  get id() {
+    return this._definition.id;
+  }
+
+  /**
+   * A short, descriptive string to identify this feature to users.
+   * @type string
+   */
+  get title() {
+    return this._definition.title;
+  }
+
+  /**
+   * A longer string to show to users that explains the feature.
+   * @type string
+   */
+  get description() {
+    return this._definition.description;
+  }
+
+  /**
+   * Whether this feature requires a browser restart to take effect after toggling.
+   * @type boolean
+   */
+  get restartRequired() {
+    return this._definition.restartRequired;
+  }
+
+  /**
+   * The type of feature. Currently only booleans are supported. This may be
+   * richer than JS types in the future, such as enum values.
+   * @type string
+   */
+  get type() {
+    return this._definition.type;
+  }
+
+  /**
+   * The name of the preference that stores the value of this feature.
+   *
+   * This preference should not be read directly, but instead its values should
+   * be accessed via FeatureGate#addObserver or FeatureGate#getValue. This
+   * property is provided for backwards compatibility.
+   *
+   * @type string
+   */
+  get preference() {
+    return this._definition.preference;
+  }
+
+  /**
+   * The default value for the feature gate for this update channel.
+   * @type boolean
+   */
+  get defaultValue() {
+    return this._definition.defaultValue;
+  }
+
+  /**
+   * If this feature should be exposed to users in an advanced settings panel
+   * for this build of Firefox.
+   *
+   * @type boolean
+   */
+  get isPublic() {
+    return this._definition.isPublic;
+  }
+
+  /**
+   * Bug numbers associated with this feature.
+   * @type Array<number>
+   */
+  get bugNumbers() {
+    return this._definition.bugNumbers;
+  }
+
+  /**
+   * Get the current value of this feature gate. Implementors should avoid
+   * storing the result to avoid missing changes to the feature's value.
+   * Consider using :func:`addObserver` if it is necessary to store the value
+   * of the feature.
+   *
+   * @async
+   * @returns {Promise<boolean>} A promise for the value associated with this feature.
+   */
+  // Note that this is async for potential future use of a storage backend besides preferences.
+  async getValue() {
+    return Services.prefs.getBoolPref(this.preference, this.defaultValue);
+  }
+
+  /**
+   * An alias of `getValue` for boolean typed feature gates.
+   *
+   * @async
+   * @returns {Promise<boolean>} A promise for the value associated with this feature.
+   * @throws {Error} If the feature is not a boolean.
+   */
+  // Note that this is async for potential future use of a storage backend besides preferences.
+  async isEnabled() {
+    if (this.type !== "boolean") {
+      throw new Error(
+        `Tried to call isEnabled when type is not boolean (it is ${this.type})`
+      );
+    }
+    return this.getValue();
+  }
+
+  /**
+   * Add an observer for changes to this feature. When the observer is added,
+   * `onChange` will asynchronously be called with the current value of the
+   * preference. If the feature is of type boolean and currently enabled,
+   * `onEnable` will additionally be called.
+   *
+   * @param {object} observer Functions to be called when the feature changes.
+   *        All observer functions are optional.
+   * @param {Function()} [observer.onEnable] Called when the feature becomes enabled.
+   * @param {Function()} [observer.onDisable] Called when the feature becomes disabled.
+   * @param {Function(newValue: boolean)} [observer.onChange] Called when the
+   *        feature's state changes to any value. The new value will be passed to the
+   *        function.
+   * @returns {Promise<boolean>} The current value of the feature.
+   */
+  async addObserver(observer) {
+    if (this._observers.size === 0) {
+      Services.prefs.addObserver(this.preference, this);
+    }
+
+    this._observers.add(observer);
+
+    if (this.type === "boolean" && (await this.isEnabled())) {
+      this._callObserverMethod(observer, "onEnable");
+    }
+    // onDisable should not be called, because features should be assumed
+    // disabled until onEnabled is called for the first time.
+
+    return this.getValue();
+  }
+
+  /**
+   * Remove an observer of changes from this feature
+   * @param observer The observer that was passed to addObserver to remove.
+   */
+  removeObserver(observer) {
+    this._observers.delete(observer);
+    if (this._observers.size === 0) {
+      Services.prefs.removeObserver(this.preference, this);
+    }
+  }
+
+  /**
+   * Removes all observers from this instance of the feature gate.
+   */
+  removeAllObservers() {
+    if (this._observers.size > 0) {
+      this._observers.clear();
+      Services.prefs.removeObserver(this.preference, this);
+    }
+  }
+
+  _callObserverMethod(observer, method, ...args) {
+    if (method in observer) {
+      try {
+        observer[method](...args);
+      } catch (err) {
+        Cu.reportError(err);
+      }
+    }
+  }
+
+  /**
+   * Observes changes to the preference storing the enabled state of the
+   * feature. The observer is dynamically added only when observer have been
+   * added.
+   * @private
+   */
+  async observe(aSubject, aTopic, aData) {
+    if (aTopic === "nsPref:changed" && aData === this.preference) {
+      const value = await this.getValue();
+      for (const observer of this._observers) {
+        this._callObserverMethod(observer, "onChange", value);
+
+        if (value) {
+          this._callObserverMethod(observer, "onEnable");
+        } else {
+          this._callObserverMethod(observer, "onDisable");
+        }
+      }
+    } else {
+      Cu.reportError(
+        new Error(`Unexpected event observed: ${aSubject}, ${aTopic}, ${aData}`)
+      );
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/Features.toml
@@ -0,0 +1,9 @@
+[demo-feature]
+title = "Demo Feature"
+description = "A no-op feature to demo the feature gate system."
+restart-required = false
+preference = "foo.bar.baz"
+type = "boolean"
+bug-numbers = [1479127]
+is-public = true
+default-value = false
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/docs/index.rst
@@ -0,0 +1,147 @@
+.. _components/featuregates:
+
+=============
+Feature Gates
+=============
+
+A feature gate is a high level tool to turn features on and off. It provides
+metadata about features, a simple, opinionated API, and avoid many potential
+pitfalls of other systems, such as using preferences directly. It is designed
+to be compatible with tools that want to know and affect the state of
+features in Firefox over time and in the wild.
+
+Feature Definitions
+===================
+
+All features must have a definition, specified in
+``toolkit/components/featuregates/Features.toml``. These definitions include
+data such as title and description (to be shown to users), and bug numbers (to
+track the development of the feature over time). Here is an example feature
+definition with an id of ``demo-feature``:
+
+.. code-block:: toml
+
+   [demo-feature]
+   title = "Demo Feature"
+   description = "A no-op feature to demo the feature gate system."
+   restart-required = false
+   bug-numbers = [1479127]
+   type = boolean
+   is-public = {default = false, nightly = true}
+   default-value = {default = false, nightly = true}
+
+.. _targeted value:
+
+Targeted values
+---------------
+
+Several fields can take a value that indicates it varies by channel and OS.
+These are known as *targeted values*. The simplest computed value is to
+simply provide the value:
+
+.. code-block:: toml
+
+   default-value: true
+
+A more interesting example is to make a feature default to true on Nightly,
+but be disabled otherwise. That would look like this:
+
+.. code-block:: toml
+
+   default-value: {default: false, nightly: true}
+
+Values can depend on multiple conditions. For example, to enable a feature
+only on Nightly running on Windows:
+
+.. code-block:: toml
+
+   default-value: {default: false, "nightly,win": true}
+
+Multiple sets of conditions can be specified, however use caution here: if
+multiple sets could match (except ``default``), the set chosen is undefined.
+An example of safely using multiple conditions:
+
+.. code-block:: toml
+
+   default-value: {default: false, nightly: true, "beta,win": true}
+
+The ``default`` condition is required. It is used as a fallback in case no
+more-specific case matches. The conditions allowed are
+
+* ``default``
+* ``release``
+* ``beta``
+* ``dev-edition``
+* ``nightly``
+* ``esr``
+* ``win``
+* ``mac``
+* ``linux``
+* ``android``
+
+Fields
+------
+
+title
+    Required. A human readable name for the feature, meant to be shown to
+    users. Should fit onto a single line.
+
+description
+    Required. A human readable description for the feature, meant to be shown to
+    users. Should be at most a paragraph.
+
+bug-numbers
+    Required. A list of bug numbers related to this feature. This should
+    likely be the metabug for the the feature, but any related bugs can be
+    included. At least one bug is required.
+
+restart-required
+    Required. If this feature requires a the browser to be restarted for changes
+    to take effect, this field should be ``true``. Otherwise, the field should
+    be ``false``. Features should aspire to not require restarts and react to
+    changes to the preference dynamically.
+
+type
+    Required. The type of value this feature relates to. The only legal value is
+    ``boolean``, but more may be added in the future.
+
+preference
+    Optional. The preference used to track the feature. If a preference is not
+    provided, one will be automatically generated based on the feature ID. It is
+    not recommended to specify a preference directly, except to integrate with
+    older code. In the future, alternate storage mechanisms may be used if a
+    preference is not supplied.
+
+default-value
+    Optional. This is a `targeted value`_ describing
+    the value for the feature if no other changes have been made, such as in
+    a fresh profile. If not provided, the default for a boolean type feature
+    gate will be ``false`` for all profiles.
+
+is-public
+    Optional. This is a `targeted value`_ describing
+    on which branches this feature should be exposed to users. When a feature
+    is made public, it may show up in a future UI that allows users to opt-in
+    to experimental features. This is not related to ``about:preferences`` or
+    ``about:config``. If not provided, the default is to make a feature
+    private for all channels.
+
+
+Feature Gate API
+================
+
+..
+    (comment) The below lists should be kept in sync with the contents of the
+    classes they are documenting. An explicit list is used so that the
+    methods can be put in a particular order.
+
+.. js:autoclass:: FeatureGate
+   :members: addObserver, removeObserver, isEnabled, fromId
+
+.. js:autoclass:: FeatureGateImplementation
+   :members: id, title, description, type, bugNumbers, isPublic, defaultValue, restartRequired, preference, addObserver, removeObserver, removeAllObservers, getValue, isEnabled
+
+   Feature implementors should use the methods :func:`fromId`,
+   :func:`addListener`, :func:`removeListener` and
+   :func:`removeAllListeners`. Additionally, metadata is available for UI and
+   analysis.
new file mode 100755
--- /dev/null
+++ b/toolkit/components/featuregates/gen_feature_definitions.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python
+import json
+import pytoml
+import re
+import sys
+
+import six
+import voluptuous
+import voluptuous.humanize
+from voluptuous import Schema, Optional, Any, All, Required, Length, Range, Msg, Match
+
+
+Text = Any(six.text_type, six.binary_type)
+
+
+id_regex = re.compile(r'^[a-z0-9-]+$')
+feature_schema = Schema({
+    Match(id_regex): {
+        Required('title'): All(Text, Length(min=1)),
+        Required('description'): All(Text, Length(min=1)),
+        Required('bug-numbers'): All(Length(min=1), [All(int, Range(min=1))]),
+        Required('restart-required'): bool,
+        Required('type'): 'boolean', # In the future this may include other types
+        Optional('preference'): Text,
+        Optional('default-value'): Any(bool, dict), # the types of the keys here should match the value of `type`
+        Optional('is-public'): Any(bool, dict),
+    },
+})
+
+
+EXIT_OK = 0
+EXIT_ERROR = 1
+
+def main(output, *filenames):
+    features = {}
+    errors = False
+    try:
+        features = process_files(filenames)
+        json.dump(features, output)
+    except ExceptionGroup as error_group:
+        print(str(error_group))
+        return EXIT_ERROR
+    return EXIT_OK
+
+
+class ExceptionGroup(Exception):
+    def __init__(self, errors):
+        self.errors = errors
+
+    def __str__(self):
+        rv = ['There were errors while processing feature definitions:']
+        for error in self.errors:
+            # indent the message
+            s = '\n'.join('    ' + line for line in str(error).split('\n'))
+            # add a * at the beginning of the first line
+            s = '  * ' + s[4:]
+            rv.append(s)
+        return '\n'.join(rv)
+
+
+class FeatureGateException(Exception):
+    def __init__(self, message, filename=None):
+        super(FeatureGateException, self).__init__(message)
+        self.filename = filename
+
+    def __str__(self):
+        message = super(FeatureGateException, self).__str__()
+        rv = ["In"]
+        if self.filename is None:
+            rv.append("unknown file:")
+        else:
+            rv.append('file "{}":'.format(self.filename))
+        rv.append(message)
+        return ' '.join(rv)
+
+    def __repr__(self):
+        # Turn "FeatureGateExcept(<message>,)" into "FeatureGateException(<message>, filename=<filename>)"
+        original = super(FeatureGateException, self).__repr__()
+        return original[:-1] + ' filename={!r})'.format(self.filename)
+
+
+def process_files(filenames):
+    features = {}
+    errors = []
+
+    for filename in filenames:
+        try:
+            with open(filename, 'r') as f:
+                feature_data = pytoml.load(f)
+
+            voluptuous.humanize.validate_with_humanized_errors(feature_data, feature_schema)
+
+            for feature_id, feature in feature_data.items():
+                feature['id'] = feature_id
+                features[feature_id] = expand_feature(feature)
+        except (voluptuous.error.Error, IOError, FeatureGateException) as e:
+            # Wrap errors in enough information to know which file they came from
+            errors.append(FeatureGateException(e, filename))
+        except pytoml.TomlError as e:
+            # Toml errors have file information already
+            errors.append(e)
+
+    if errors:
+        raise ExceptionGroup(errors)
+
+    return features
+
+
+def hyphens_to_camel_case(s):
+    """Convert names-with-hyphens to namesInCamelCase"""
+    rv = ''
+    for part in s.split('-'):
+        if rv == '':
+            rv = part.lower()
+        else:
+            rv += part[0].upper() + part[1:].lower()
+    return rv
+
+
+
+def expand_feature(feature):
+    """Fill in default values for optional fields"""
+
+    # convert all names-with-hyphens to namesInCamelCase
+    key_changes = []
+    for key in feature.keys():
+        if '-' in key:
+            new_key = hyphens_to_camel_case(key)
+            key_changes.append((key, new_key))
+
+    for (old_key, new_key) in key_changes:
+        feature[new_key] = feature[old_key]
+        del feature[old_key]
+
+    if feature['type'] == 'boolean':
+        feature.setdefault('preference', 'features.{}.enabled'.format(feature['id']))
+        feature.setdefault('defaultValue', False)
+    elif 'preference' not in feature:
+        raise FeatureGateException(
+            'Features of type {} must specify an explicit preference name'.format(feature['type'])
+        )
+
+    feature.setdefault('isPublic', False)
+
+    try:
+        for key in ['defaultValue', 'isPublic']:
+            feature[key] = process_configured_value(key, feature[key])
+    except FeatureGateException as e:
+        raise FeatureGateException(
+            "Error when processing feature {}: {}".format(feature['id'], e.message))
+
+    return feature
+
+
+def process_configured_value(name, value):
+    if not isinstance(value, dict):
+        return {'default': value}
+
+    if 'default' not in value:
+        raise FeatureGateException("Config for {} has no default: {}".format(name, value))
+
+    expected_keys = set({'default', 'win', 'mac', 'linux', 'android', 'nightly', 'beta', 'release', 'dev-edition', 'esr'})
+
+    for key in value.keys():
+        parts = [p.strip() for p in key.split(",")]
+        for part in parts:
+            if part not in expected_keys:
+                raise FeatureGateException(
+                    "Unexpected target {}, expected any of {}".format(part, expected_keys)
+                )
+
+    # TODO Compute values at build time, so that it always returns only a single value.
+
+    return value
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.stdout, *sys.argv[1:]))
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/jar.mn
@@ -0,0 +1,9 @@
+# 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/.
+
+toolkit.jar:
+% resource featuregates %res/featuregates/
+  res/featuregates/FeatureGate.jsm (./FeatureGate.jsm)
+  res/featuregates/FeatureGateImplementation.jsm (./FeatureGateImplementation.jsm)
+  res/featuregates/feature_definitions.json (./feature_definitions.json)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/moz.build
@@ -0,0 +1,25 @@
+# -*- 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')
+
+SPHINX_TREES['featuregates'] = 'docs'
+
+XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+PYTHON_UNITTEST_MANIFESTS += ['test/python/python.ini']
+
+JAR_MANIFESTS += ['jar.mn']
+
+GENERATED_FILES = [
+    'feature_definitions.json',
+]
+
+feature_files = ['Features.toml']
+
+feature_defs = GENERATED_FILES['feature_definitions.json']
+feature_defs.script = 'gen_feature_definitions.py'
+feature_defs.inputs = feature_files
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/python/data/empty_feature.toml
@@ -0,0 +1,1 @@
+[empty-feature]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/python/data/good.toml
@@ -0,0 +1,16 @@
+[demo-feature]
+title = "Demo Feature"
+description = "A no-op feature to demo the feature gate system."
+restart-required = false
+preference = "foo.bar.baz"
+type = "boolean"
+bug-numbers = [1479127]
+is-public = true
+default-value = false
+
+[minimal-feature]
+title = "Minimal Feature"
+description = "The smallest feature that is valid"
+restart-required = true
+type = "boolean"
+bug-numbers = [1479127]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/python/data/invalid_toml.toml
@@ -0,0 +1,1 @@
+this: is: not: valid: toml
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/python/python.ini
@@ -0,0 +1,1 @@
+[test_gen_feature_definitions.py]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/python/test_gen_feature_definitions.py
@@ -0,0 +1,302 @@
+import json
+import sys
+import unittest
+from os import path
+from textwrap import dedent
+
+import mozunit
+import pytoml
+import six
+import voluptuous
+
+
+if six.PY3:
+    from io import StringIO
+else:
+    from StringIO import StringIO
+
+
+FEATURE_GATES_ROOT_PATH = path.abspath(path.join(path.dirname(__file__), path.pardir, path.pardir))
+sys.path.append(FEATURE_GATES_ROOT_PATH)
+from gen_feature_definitions import (
+    ExceptionGroup,
+    expand_feature,
+    feature_schema,
+    FeatureGateException,
+    hyphens_to_camel_case,
+    main,
+    process_configured_value,
+    process_files,
+)
+
+
+def make_test_file_path(name):
+    return path.join(FEATURE_GATES_ROOT_PATH, 'test', 'python', 'data', name + '.toml')
+
+
+def minimal_definition(**kwargs):
+    defaults = {
+        'id': 'test-feature',
+        'title': 'Test Feature',
+        'description': 'A feature for testing things',
+        'bug-numbers': [1479127],
+        'restart-required': False,
+        'type': 'boolean',
+    }
+    defaults.update(dict([(k.replace('_', '-'), v) for k, v in kwargs.items()]))
+    return defaults
+
+
+class TestHyphensToCamelCase(unittest.TestCase):
+    simple_cases = [
+        ('', ''),
+        ('singleword', 'singleword'),
+        ('more-than-one-word', 'moreThanOneWord'),
+    ]
+
+    def test_simple_cases(self):
+        for in_string, out_string in self.simple_cases:
+            assert hyphens_to_camel_case(in_string) == out_string
+
+
+class TestExceptionGroup(unittest.TestCase):
+    def test_str_indentation_of_grouped_lines(self):
+        errors = [
+            Exception("single line error 1"),
+            Exception("single line error 2"),
+            Exception("multiline\nerror 1"),
+            Exception("multiline\nerror 2"),
+        ]
+
+        assert str(ExceptionGroup(errors)) == dedent("""\
+        There were errors while processing feature definitions:
+          * single line error 1
+          * single line error 2
+          * multiline
+            error 1
+          * multiline
+            error 2""")
+
+class TestFeatureGateException(unittest.TestCase):
+    def test_str_no_file(self):
+        error = FeatureGateException("oops")
+        assert str(error) == "In unknown file: oops"
+
+    def test_str_with_file(self):
+        error = FeatureGateException("oops", filename="some/bad/file.txt")
+        assert str(error) == 'In file "some/bad/file": oops'
+
+    def test_repr_no_file(self):
+        error = FeatureGateException("oops")
+        assert repr(error) == "FeatureGateException('oops', filename=None)"
+
+    def test_str_with_file(self):
+        error = FeatureGateException("oops", filename="some/bad/file.txt")
+        assert repr(error) == "FeatureGateException('oops', filename='some/bad/file.txt')"
+
+
+class TestProcessFiles(unittest.TestCase):
+    def test_valid_file(self):
+        filename = make_test_file_path('good')
+        result = process_files([filename])
+        assert result == {
+            "demo-feature": {
+                "id": "demo-feature",
+                "title": "Demo Feature",
+                "description": "A no-op feature to demo the feature gate system.",
+                "restartRequired": False,
+                "preference": "foo.bar.baz",
+                "type": "boolean",
+                "bugNumbers": [1479127],
+                "isPublic": {"default": True},
+                "defaultValue": {"default": False},
+            },
+            "minimal-feature": {
+                "id": "minimal-feature",
+                "title": "Minimal Feature",
+                "description": "The smallest feature that is valid",
+                "restartRequired": True,
+                "preference": "features.minimal-feature.enabled",
+                "type": "boolean",
+                "bugNumbers": [1479127],
+                "isPublic": {"default": False},
+                "defaultValue": {"default": False},
+            },
+        }
+
+    def test_invalid_toml(self):
+        filename = make_test_file_path('invalid_toml')
+        with self.assertRaises(ExceptionGroup) as context:
+            process_files([filename])
+        error_group = context.exception
+        assert len(error_group.errors) == 1
+        assert type(error_group.errors[0]) == pytoml.TomlError
+
+    def test_empty_feature(self):
+        filename = make_test_file_path('empty_feature')
+        with self.assertRaises(ExceptionGroup) as context:
+            process_files([filename])
+        error_group = context.exception
+        assert len(error_group.errors) == 1
+        assert type(error_group.errors[0]) == FeatureGateException
+        assert 'required key not provided' in str(error_group.errors[0])
+
+    def test_missing_file(self):
+        filename = make_test_file_path('file_does_not_exist')
+        with self.assertRaises(ExceptionGroup) as context:
+            process_files([filename])
+        error_group = context.exception
+        assert len(error_group.errors) == 1
+        assert type(error_group.errors[0]) == FeatureGateException
+        assert 'No such file or directory' in str(error_group.errors[0])
+
+
+class TestFeatureSchema(unittest.TestCase):
+
+    def make_test_features(self, *overrides):
+        if len(overrides) == 0:
+            overrides = [{}]
+        features = {}
+        for override in overrides:
+            feature = minimal_definition(**override)
+            feature_id = feature.pop('id')
+            features[feature_id] = feature
+        return features
+
+    def test_minimal_valid(self):
+        definition = self.make_test_features()
+        # should not raise an exception
+        feature_schema(definition)
+
+    def test_extra_keys_not_allowed(self):
+        definition = self.make_test_features({'unexpected_key': 'oh no!'})
+        with self.assertRaises(voluptuous.Error) as context:
+            feature_schema(definition)
+        assert 'extra keys not allowed' in str(context.exception)
+
+    def test_required_fields(self):
+        required_keys = ['title', 'description', 'bug-numbers', 'restart-required', 'type']
+        for key in required_keys:
+            definition = self.make_test_features({'id': 'test-feature'})
+            del definition['test-feature'][key]
+            with self.assertRaises(voluptuous.Error) as context:
+                feature_schema(definition)
+            assert 'required key not provided' in str(context.exception)
+            assert key in str(context.exception)
+
+    def test_nonempty_keys(self):
+        test_parameters = [
+            ('title', ''),
+            ('description', ''),
+            ('bug-numbers', [])
+        ]
+        for key, empty in test_parameters:
+            definition = self.make_test_features({key: empty})
+            with self.assertRaises(voluptuous.Error) as context:
+                feature_schema(definition)
+            assert 'length of value must be at least' in str(context.exception)
+            assert "['{}']".format(key) in str(context.exception)
+
+
+class ExpandFeatureTests(unittest.TestCase):
+
+    def test_hyphenation_to_snake_case(self):
+        feature = minimal_definition()
+        assert 'bug-numbers' in feature
+        assert 'bugNumbers' in expand_feature(feature)
+
+    def test_default_value_default(self):
+        feature = minimal_definition(type='boolean')
+        assert 'default-value' not in feature
+        assert 'defaultValue' not in feature
+        assert expand_feature(feature)['defaultValue'] == {'default': False}
+
+    def test_default_value_override_constant(self):
+        feature = minimal_definition(type='boolean', default_value=True)
+        assert expand_feature(feature)['defaultValue'] == {'default': True}
+
+    def test_default_value_override_configured_value(self):
+        feature = minimal_definition(type='boolean', default_value={'default': False, 'nightly': True})
+        assert expand_feature(feature)['defaultValue'] == {'default': False, 'nightly': True}
+
+    def test_preference_default(self):
+        feature = minimal_definition(type='boolean')
+        assert 'preference' not in feature
+        assert expand_feature(feature)['preference'] == 'features.test-feature.enabled'
+
+    def test_preference_override(self):
+        feature = minimal_definition(preference='test.feature.a')
+        assert expand_feature(feature)['preference'] == 'test.feature.a'
+
+
+class ProcessConfiguredValueTests(unittest.TestCase):
+
+    def test_expands_single_values(self):
+        for value in [True, False, 2, 'features']:
+            assert process_configured_value('test', value) == {'default': value}
+
+    def test_default_key_is_required(self):
+        with self.assertRaises(FeatureGateException) as context:
+            assert process_configured_value('test', {'nightly': True})
+        assert 'has no default' in str(context.exception)
+
+    def test_invalid_keys_rejected(self):
+        with self.assertRaises(FeatureGateException) as context:
+            assert process_configured_value('test', {'default': True, 'bogus': True})
+        assert 'Unexpected target bogus' in str(context.exception)
+
+    def test_simple_key(self):
+        value = {'nightly': True, 'default': False}
+        assert process_configured_value('test', value) == value
+
+    def test_compound_keys(self):
+        value = {'win,nightly': True, 'default': False}
+        assert process_configured_value('test', value) == value
+
+    def test_multiple_keys(self):
+        value = {'win': True, 'mac': True, 'default': False}
+        assert process_configured_value('test', value) == value
+
+
+class MainTests(unittest.TestCase):
+
+    def test_it_outputs_json(self):
+        output = StringIO()
+        filename = make_test_file_path('good')
+        main(output, filename)
+        output.seek(0)
+        results = json.load(output)
+        assert results == {
+            u"demo-feature": {
+                u"id": u"demo-feature",
+                u"title": u"Demo Feature",
+                u"description": u"A no-op feature to demo the feature gate system.",
+                u"restartRequired": False,
+                u"preference": u"foo.bar.baz",
+                u"type": u"boolean",
+                u"bugNumbers": [1479127],
+                u"isPublic": {u"default": True},
+                u"defaultValue": {u"default": False},
+            },
+            u"minimal-feature": {
+                u"id": u"minimal-feature",
+                u"title": u"Minimal Feature",
+                u"description": u"The smallest feature that is valid",
+                u"restartRequired": True,
+                u"preference": u"features.minimal-feature.enabled",
+                u"type": u"boolean",
+                u"bugNumbers": [1479127],
+                u"isPublic": {u"default": False},
+                u"defaultValue": {u"default": False},
+            },
+        }
+
+    def test_it_returns_1_for_errors(self):
+        output = StringIO()
+        filename = make_test_file_path('invalid_toml')
+        assert main(output, filename) == 1
+        assert output.getvalue() == ''
+
+
+if __name__ == '__main__':
+    mozunit.main(*sys.argv[1:])
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/unit/.eslintrc.js
@@ -0,0 +1,11 @@
+"use strict";
+
+module.exports = {
+  extends: [
+    "plugin:mozilla/xpcshell-test"
+  ],
+
+  plugins: [
+    "mozilla"
+  ],
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/unit/head.js
@@ -0,0 +1,9 @@
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// ================================================
+// Load mocking/stubbing library, sinon
+// docs: http://sinonjs.org/releases/v2.3.2/
+ChromeUtils.import("resource://gre/modules/Timer.jsm");
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js", this);
+/* global sinon */
+// ================================================
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/unit/test_FeatureGate.js
@@ -0,0 +1,264 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://featuregates/FeatureGate.jsm", this);
+ChromeUtils.import("resource://featuregates/FeatureGateImplementation.jsm", this);
+ChromeUtils.import("resource://testing-common/httpd.js", this);
+
+const kDefinitionDefaults = {
+  id: "test-feature",
+  title: "Test Feature",
+  description: "A feature for testing",
+  restartRequired: false,
+  type: "boolean",
+  preference: "test.feature",
+  defaultValue: false,
+  isPublic: false,
+};
+
+function definitionFactory(override = {}) {
+  return Object.assign({}, kDefinitionDefaults, override);
+}
+
+class DefinitionServer {
+  constructor(definitionOverrides = []) {
+    this.server = new HttpServer();
+    this.server.registerPathHandler("/definitions.json", this);
+    this.definitions = {};
+
+    for (const override of definitionOverrides) {
+      this.addDefinition(override);
+    }
+
+    this.server.start();
+    registerCleanupFunction(() => new Promise(resolve => this.server.stop(resolve)));
+  }
+
+  // for nsIHttpRequestHandler
+  handle(request, response) {
+    // response.setHeader("Content-Type", "application/json");
+    response.write(JSON.stringify(this.definitions));
+  }
+
+  get definitionsUrl() {
+    const {primaryScheme, primaryHost, primaryPort} = this.server.identity;
+    return `${primaryScheme}://${primaryHost}:${primaryPort}/definitions.json`;
+  }
+
+  addDefinition(overrides = {}) {
+    const definition = definitionFactory(overrides);
+    // convert targeted values, used by fromId
+    definition.isPublic = {default: definition.isPublic};
+    definition.defaultValue = {default: definition.defaultValue};
+    this.definitions[definition.id] = definition;
+    return definition;
+  }
+}
+
+// ============================================================================
+
+// The getters and setters should read correctly from the definition
+add_task(async function testReadFromDefinition() {
+  const server = new DefinitionServer();
+  const definition = server.addDefinition({id: "test-feature"});
+  const feature = await FeatureGate.fromId("test-feature", server.definitionsUrl);
+
+  // simple fields
+  equal(feature.id, definition.id, "id should be read from definition");
+  equal(feature.title, definition.title, "title should be read from definition");
+  equal(feature.description, definition.description, "description should be read from definition");
+  equal(feature.restartRequired, definition.restartRequired, "restartRequired should be read from definition");
+  equal(feature.type, definition.type, "type should be read from definition");
+  equal(feature.preference, definition.preference, "preference should be read from definition");
+
+  // targeted fields
+  equal(feature.defaultValue, definition.defaultValue.default, "defaultValue should be processed as a targeted value");
+  equal(feature.isPublic, definition.isPublic.default, "isPublic should be processed as a targeted value");
+
+  // cleanup
+  Services.prefs.getDefaultBranch("").deleteBranch("test.feature");
+});
+
+// Targeted values should return the correct value
+add_task(async function testTargetedValues() {
+  const backstage = ChromeUtils.import("resource://featuregates/FeatureGate.jsm", {});
+  const targetingFacts = new Map(Object.entries({true1: true, true2: true, false1: false, false2: false}));
+
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo"}, targetingFacts),
+    "foo",
+    "A lone default value should be returned",
+  );
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo", true1: "bar"}, targetingFacts),
+    "bar",
+    "A true target should override the default",
+  );
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo", false1: "bar"}, targetingFacts),
+    "foo",
+    "A false target should not overrides the default",
+  );
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo", "true1,true2": "bar"}, targetingFacts),
+    "bar",
+    "A compound target of two true targets should override the default",
+  );
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo", "true1,false1": "bar"}, targetingFacts),
+    "foo",
+    "A compound target of a true target and a false target should not override the default",
+  );
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo", "false1,false2": "bar"}, targetingFacts),
+    "foo",
+    "A compound target of two false targets should not override the default",
+  );
+  Assert.equal(
+    backstage.evaluateTargetedValue({default: "foo", false1: "bar", true1: "baz"}, targetingFacts),
+    "baz",
+    "A true target should override the default when a false target is also present",
+  );
+});
+
+// getValue should work
+add_task(async function testGetValue() {
+  equal(
+    Services.prefs.getPrefType("test.feature.1"),
+    Services.prefs.PREF_INVALID,
+    "Before creating the feature gate, the preference should not exist",
+  );
+
+  const server = new DefinitionServer([
+    {id: "test-feature-1", defaultValue: false, preference: "test.feature.1"},
+    {id: "test-feature-2", defaultValue: true, preference: "test.feature.2"},
+  ]);
+
+  equal(
+    await FeatureGate.getValue("test-feature-1", server.definitionsUrl),
+    false,
+    "getValue() starts by returning the default value",
+  );
+  equal(
+    await FeatureGate.getValue("test-feature-2", server.definitionsUrl),
+    true,
+    "getValue() starts by returning the default value",
+  );
+
+  Services.prefs.setBoolPref("test.feature.1", true);
+  equal(
+    await FeatureGate.getValue("test-feature-1", server.definitionsUrl),
+    true,
+    "getValue() return the new value",
+  );
+
+  Services.prefs.setBoolPref("test.feature.1", false);
+  equal(
+    await FeatureGate.getValue("test-feature-1", server.definitionsUrl),
+    false,
+    "getValue() should return the second value",
+  );
+
+  // cleanup
+  Services.prefs.getDefaultBranch("").deleteBranch("test.feature.");
+});
+
+// getValue should work
+add_task(async function testGetValue() {
+  const server = new DefinitionServer([
+    {id: "test-feature-1", defaultValue: false, preference: "test.feature.1"},
+    {id: "test-feature-2", defaultValue: true, preference: "test.feature.2"},
+  ]);
+
+  equal(
+    Services.prefs.getPrefType("test.feature.1"),
+    Services.prefs.PREF_INVALID,
+    "Before creating the feature gate, the first preference should not exist",
+  );
+  equal(
+    Services.prefs.getPrefType("test.feature.2"),
+    Services.prefs.PREF_INVALID,
+    "Before creating the feature gate, the second preference should not exist",
+  );
+
+  equal(
+    await FeatureGate.isEnabled("test-feature-1", server.definitionsUrl),
+    false,
+    "isEnabled() starts by returning the default value",
+  );
+  equal(
+    await FeatureGate.isEnabled("test-feature-2", server.definitionsUrl),
+    true,
+    "isEnabled() starts by returning the default value",
+  );
+
+  Services.prefs.setBoolPref("test.feature.1", true);
+  equal(
+    await FeatureGate.isEnabled("test-feature-1", server.definitionsUrl),
+    true,
+    "isEnabled() return the new value",
+  );
+
+  Services.prefs.setBoolPref("test.feature.1", false);
+  equal(
+    await FeatureGate.isEnabled("test-feature-1", server.definitionsUrl),
+    false,
+    "isEnabled() should return the second value",
+  );
+
+  // cleanup
+  Services.prefs.getDefaultBranch("").deleteBranch("test.feature.");
+});
+
+
+// adding and removing event observers should work
+add_task(async function testGetValue() {
+  const preference = "test.pref";
+  const server = new DefinitionServer([{id: "test-feature", defaultValue: false, preference}]);
+  const observer = {
+    onChange: sinon.stub(),
+    onEnable: sinon.stub(),
+    onDisable: sinon.stub(),
+  };
+
+  let rv = await FeatureGate.addObserver("test-feature", observer, server.definitionsUrl);
+  equal(rv, false, "addObserver returns the current value");
+
+  Assert.deepEqual(observer.onChange.args, [], "onChange should not be called");
+  Assert.deepEqual(observer.onEnable.args, [], "onEnable should not be called");
+  Assert.deepEqual(observer.onDisable.args, [], "onDisable should not be called");
+
+  Services.prefs.setBoolPref(preference, true);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true]], "onChange should be called with the new value");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should be called");
+  Assert.deepEqual(observer.onDisable.args, [], "onDisable should not be called");
+
+  Services.prefs.setBoolPref(preference, false);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true], [false]], "onChange should be called again with the new value");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should not be called a second time");
+  Assert.deepEqual(observer.onDisable.args, [[]], "onDisable should be called for the first time");
+
+  Services.prefs.setBoolPref(preference, false);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true], [false]], "onChange should not be called if the value did not change");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should not be called again if the value did not change");
+  Assert.deepEqual(observer.onDisable.args, [[]], "onDisable should not be called if the value did not change");
+
+  // remove the listener and make sure the observer isn't called again
+  FeatureGate.removeObserver("test-feature", observer);
+  await Promise.resolve(); // Allow events to be called async
+
+  Services.prefs.setBoolPref(preference, true);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true], [false]], "onChange should not be called after observer was removed");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should not be called after observer was removed");
+  Assert.deepEqual(observer.onDisable.args, [[]], "onDisable should not be called after observer was removed");
+
+  // cleanup
+  Services.prefs.getDefaultBranch("").deleteBranch(preference);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/unit/test_FeatureGateImplementation.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://featuregates/FeatureGate.jsm", this);
+ChromeUtils.import("resource://featuregates/FeatureGateImplementation.jsm", this);
+ChromeUtils.import("resource://testing-common/httpd.js", this);
+
+const kDefinitionDefaults = {
+  id: "test-feature",
+  title: "Test Feature",
+  description: "A feature for testing",
+  restartRequired: false,
+  type: "boolean",
+  preference: "test.feature",
+  defaultValue: false,
+  isPublic: false,
+};
+
+function definitionFactory(override = {}) {
+  return Object.assign({}, kDefinitionDefaults, override);
+}
+
+class DefinitionServer {
+  constructor(definitionOverrides = []) {
+    this.server = new HttpServer();
+    this.server.registerPathHandler("/definitions.json", this);
+    this.definitions = {};
+
+    for (const override of definitionOverrides) {
+      this.addDefinition(override);
+    }
+
+    this.server.start();
+    registerCleanupFunction(() => new Promise(resolve => this.server.stop(resolve)));
+  }
+
+  // for nsIHttpRequestHandler
+  handle(request, response) {
+    // response.setHeader("Content-Type", "application/json");
+    response.write(JSON.stringify(this.definitions));
+  }
+
+  get definitionsUrl() {
+    const {primaryScheme, primaryHost, primaryPort} = this.server.identity;
+    return `${primaryScheme}://${primaryHost}:${primaryPort}/definitions.json`;
+  }
+
+  addDefinition(overrides = {}) {
+    const definition = definitionFactory(overrides);
+    // convert targeted values, used by fromId
+    definition.isPublic = {default: definition.isPublic};
+    definition.defaultValue = {default: definition.defaultValue};
+    this.definitions[definition.id] = definition;
+    return definition;
+  }
+}
+
+// getValue should work
+add_task(async function testGetValue() {
+  const preference = "test.pref";
+  equal(
+    Services.prefs.getPrefType(preference),
+    Services.prefs.PREF_INVALID,
+    "Before creating the feature gate, the preference should not exist",
+  );
+  const feature = new FeatureGateImplementation(definitionFactory({ preference, defaultValue: false }));
+  equal(
+    Services.prefs.getBoolPref(preference),
+    false,
+    "Creating a preference should set its default value",
+  );
+  equal(await feature.getValue(), false, "getValue() should return the same value");
+
+  Services.prefs.setBoolPref(preference, true);
+  equal(await feature.getValue(), true, "getValue() should return the new value");
+
+  Services.prefs.setBoolPref(preference, false);
+  equal(await feature.getValue(), false, "getValue() should return the third value");
+
+  // cleanup
+  Services.prefs.getDefaultBranch("").deleteBranch(preference);
+});
+
+// event observers should work
+add_task(async function testGetValue() {
+  const preference = "test.pref";
+  const feature = new FeatureGateImplementation(definitionFactory({ preference, defaultValue: false }));
+  const observer = {
+    onChange: sinon.stub(),
+    onEnable: sinon.stub(),
+    onDisable: sinon.stub(),
+  };
+
+  let rv = await feature.addObserver(observer);
+  equal(rv, false, "addObserver returns the current value");
+
+  Assert.deepEqual(observer.onChange.args, [], "onChange should not be called");
+  Assert.deepEqual(observer.onEnable.args, [], "onEnable should not be called");
+  Assert.deepEqual(observer.onDisable.args, [], "onDisable should not be called");
+
+  Services.prefs.setBoolPref(preference, true);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true]], "onChange should be called with the new value");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should be called");
+  Assert.deepEqual(observer.onDisable.args, [], "onDisable should not be called");
+
+  Services.prefs.setBoolPref(preference, false);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true], [false]], "onChange should be called again with the new value");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should not be called a second time");
+  Assert.deepEqual(observer.onDisable.args, [[]], "onDisable should be called for the first time");
+
+  Services.prefs.setBoolPref(preference, false);
+  await Promise.resolve(); // Allow events to be called async
+  Assert.deepEqual(observer.onChange.args, [[true], [false]], "onChange should not be called if the value did not change");
+  Assert.deepEqual(observer.onEnable.args, [[]], "onEnable should not be called again if the value did not change");
+  Assert.deepEqual(observer.onDisable.args, [[]], "onDisable should not be called if the value did not change");
+
+  // cleanup
+  feature.removeAllObservers();
+  Services.prefs.getDefaultBranch("").deleteBranch(preference);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/featuregates/test/unit/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head.js
+tags = featuregates
+
+[test_FeatureGate.js]
+[test_FeatureGateImplementation.js]
--- a/toolkit/components/moz.build
+++ b/toolkit/components/moz.build
@@ -28,16 +28,17 @@ DIRS += [
     'commandlines',
     'contentprefs',
     'contextualidentity',
     'crashes',
     'crashmonitor',
     'downloads',
     'enterprisepolicies',
     'extensions',
+    'featuregates',
     'filewatcher',
     'finalizationwitness',
     'find',
     'fuzzyfox',
     'jsoncpp/src/lib_json',
     'lz4',
     'mediasniffer',
     'mozintl',
--- a/tools/docs/conf.py
+++ b/tools/docs/conf.py
@@ -44,16 +44,17 @@ extensions = [
 
 # JSDoc must run successfully for dirs specified, so running
 # tree-wide (the default) will not work currently.
 js_source_path = [
     'browser/components/extensions',
     'testing/marionette',
     'toolkit/components/extensions',
     'toolkit/components/extensions/parent',
+    'toolkit/components/featuregates',
     'toolkit/mozapps/extensions',
 ]
 root_for_relative_js_paths = '.'
 jsdoc_config_path = 'tools/docs/jsdoc.json'
 
 templates_path = ['_templates']
 source_suffix = '.rst'
 source_suffix = ['.rst', '.md']