mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-28 07:13:20 +00:00
Bug 1479127 - Add featuregate library r=mossop,firefox-build-system-reviewers,mshal
Differential Revision: https://phabricator.services.mozilla.com/D5175 --HG-- extra : moz-landing-system : lando
This commit is contained in:
parent
6b988936da
commit
59081613bd
@ -177,6 +177,10 @@ var whitelist = [
|
||||
{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 =>
|
||||
|
186
toolkit/components/featuregates/FeatureGate.jsm
Normal file
186
toolkit/components/featuregates/FeatureGate.jsm
Normal file
@ -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();
|
||||
}
|
||||
}
|
248
toolkit/components/featuregates/FeatureGateImplementation.jsm
Normal file
248
toolkit/components/featuregates/FeatureGateImplementation.jsm
Normal file
@ -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}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
9
toolkit/components/featuregates/Features.toml
Normal file
9
toolkit/components/featuregates/Features.toml
Normal file
@ -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
|
147
toolkit/components/featuregates/docs/index.rst
Normal file
147
toolkit/components/featuregates/docs/index.rst
Normal file
@ -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.
|
178
toolkit/components/featuregates/gen_feature_definitions.py
Executable file
178
toolkit/components/featuregates/gen_feature_definitions.py
Executable file
@ -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:]))
|
9
toolkit/components/featuregates/jar.mn
Normal file
9
toolkit/components/featuregates/jar.mn
Normal file
@ -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)
|
25
toolkit/components/featuregates/moz.build
Normal file
25
toolkit/components/featuregates/moz.build
Normal file
@ -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
|
@ -0,0 +1 @@
|
||||
[empty-feature]
|
16
toolkit/components/featuregates/test/python/data/good.toml
Normal file
16
toolkit/components/featuregates/test/python/data/good.toml
Normal file
@ -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]
|
@ -0,0 +1 @@
|
||||
this: is: not: valid: toml
|
1
toolkit/components/featuregates/test/python/python.ini
Normal file
1
toolkit/components/featuregates/test/python/python.ini
Normal file
@ -0,0 +1 @@
|
||||
[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:])
|
11
toolkit/components/featuregates/test/unit/.eslintrc.js
Normal file
11
toolkit/components/featuregates/test/unit/.eslintrc.js
Normal file
@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
"plugin:mozilla/xpcshell-test"
|
||||
],
|
||||
|
||||
plugins: [
|
||||
"mozilla"
|
||||
],
|
||||
};
|
9
toolkit/components/featuregates/test/unit/head.js
Normal file
9
toolkit/components/featuregates/test/unit/head.js
Normal file
@ -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 */
|
||||
// ================================================
|
264
toolkit/components/featuregates/test/unit/test_FeatureGate.js
Normal file
264
toolkit/components/featuregates/test/unit/test_FeatureGate.js
Normal file
@ -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);
|
||||
});
|
@ -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);
|
||||
});
|
6
toolkit/components/featuregates/test/unit/xpcshell.ini
Normal file
6
toolkit/components/featuregates/test/unit/xpcshell.ini
Normal file
@ -0,0 +1,6 @@
|
||||
[DEFAULT]
|
||||
head = head.js
|
||||
tags = featuregates
|
||||
|
||||
[test_FeatureGate.js]
|
||||
[test_FeatureGateImplementation.js]
|
@ -33,6 +33,7 @@ DIRS += [
|
||||
'downloads',
|
||||
'enterprisepolicies',
|
||||
'extensions',
|
||||
'featuregates',
|
||||
'filewatcher',
|
||||
'finalizationwitness',
|
||||
'find',
|
||||
|
@ -49,6 +49,7 @@ js_source_path = [
|
||||
'testing/marionette',
|
||||
'toolkit/components/extensions',
|
||||
'toolkit/components/extensions/parent',
|
||||
'toolkit/components/featuregates',
|
||||
'toolkit/mozapps/extensions',
|
||||
]
|
||||
root_for_relative_js_paths = '.'
|
||||
|
Loading…
Reference in New Issue
Block a user