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:
Mike Cooper 2019-01-09 20:01:52 +00:00
parent 6b988936da
commit 59081613bd
20 changed files with 1544 additions and 0 deletions

View File

@ -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 =>

View 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();
}
}

View 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}`)
);
}
}
}

View 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

View 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.

View 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:]))

View 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)

View 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

View File

@ -0,0 +1 @@
[empty-feature]

View 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]

View File

@ -0,0 +1 @@
this: is: not: valid: toml

View File

@ -0,0 +1 @@
[test_gen_feature_definitions.py]

View File

@ -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:])

View File

@ -0,0 +1,11 @@
"use strict";
module.exports = {
extends: [
"plugin:mozilla/xpcshell-test"
],
plugins: [
"mozilla"
],
};

View 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 */
// ================================================

View 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);
});

View File

@ -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);
});

View File

@ -0,0 +1,6 @@
[DEFAULT]
head = head.js
tags = featuregates
[test_FeatureGate.js]
[test_FeatureGateImplementation.js]

View File

@ -33,6 +33,7 @@ DIRS += [
'downloads',
'enterprisepolicies',
'extensions',
'featuregates',
'filewatcher',
'finalizationwitness',
'find',

View File

@ -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 = '.'