зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
6b988936da
Коммит
59081613bd
|
@ -177,6 +177,10 @@ var whitelist = [
|
||||||
{file: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg",
|
{file: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg",
|
||||||
isFromDevTools: true},
|
isFromDevTools: true},
|
||||||
{file: "chrome://devtools/skin/images/next.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 =>
|
whitelist = new Set(whitelist.filter(item =>
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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.
|
|
@ -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:]))
|
|
@ -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)
|
|
@ -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]
|
|
@ -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
|
|
@ -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:])
|
|
@ -0,0 +1,11 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
"plugin:mozilla/xpcshell-test"
|
||||||
|
],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
"mozilla"
|
||||||
|
],
|
||||||
|
};
|
|
@ -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 */
|
||||||
|
// ================================================
|
|
@ -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);
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
[DEFAULT]
|
||||||
|
head = head.js
|
||||||
|
tags = featuregates
|
||||||
|
|
||||||
|
[test_FeatureGate.js]
|
||||||
|
[test_FeatureGateImplementation.js]
|
|
@ -33,6 +33,7 @@ DIRS += [
|
||||||
'downloads',
|
'downloads',
|
||||||
'enterprisepolicies',
|
'enterprisepolicies',
|
||||||
'extensions',
|
'extensions',
|
||||||
|
'featuregates',
|
||||||
'filewatcher',
|
'filewatcher',
|
||||||
'finalizationwitness',
|
'finalizationwitness',
|
||||||
'find',
|
'find',
|
||||||
|
|
|
@ -49,6 +49,7 @@ js_source_path = [
|
||||||
'testing/marionette',
|
'testing/marionette',
|
||||||
'toolkit/components/extensions',
|
'toolkit/components/extensions',
|
||||||
'toolkit/components/extensions/parent',
|
'toolkit/components/extensions/parent',
|
||||||
|
'toolkit/components/featuregates',
|
||||||
'toolkit/mozapps/extensions',
|
'toolkit/mozapps/extensions',
|
||||||
]
|
]
|
||||||
root_for_relative_js_paths = '.'
|
root_for_relative_js_paths = '.'
|
||||||
|
|
Загрузка…
Ссылка в новой задаче