diff options
Diffstat (limited to 'toolkit/components/featuregates')
16 files changed, 2029 insertions, 0 deletions
diff --git a/toolkit/components/featuregates/FeatureGate.sys.mjs b/toolkit/components/featuregates/FeatureGate.sys.mjs new file mode 100644 index 0000000000..f8ab745151 --- /dev/null +++ b/toolkit/components/featuregates/FeatureGate.sys.mjs @@ -0,0 +1,288 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FeatureGateImplementation: + "resource://featuregates/FeatureGateImplementation.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "gFeatureDefinitionsPromise", async () => { + const url = "resource://featuregates/feature_definitions.json"; + return fetchFeatureDefinitions(url); +}); + +async function fetchFeatureDefinitions(url) { + const res = await fetch(url); + let definitionsJson = await res.json(); + return new Map(Object.entries(definitionsJson)); +} + +function buildFeatureGateImplementation(definition) { + const targetValueKeys = ["defaultValue", "isPublic"]; + for (const key of targetValueKeys) { + definition[`${key}OriginalValue`] = definition[key]; + definition[key] = FeatureGate.evaluateTargetedValue(definition[key]); + } + return new lazy.FeatureGateImplementation(definition); +} + +let featureGatePrefObserver = { + onChange() { + FeatureGate.annotateCrashReporter(); + }, + // Ignore onEnable and onDisable since onChange is called in both cases. + onEnable() {}, + onDisable() {}, +}; + +const kFeatureGateCache = new Map(); + +/** A high level control for turning features on and off. */ +export 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 lazy.gFeatureDefinitionsPromise; + } + + if (!featureDefinitions.has(id)) { + throw new Error( + `Unknown feature id ${id}. Features must be defined in toolkit/components/featuregates/Features.toml` + ); + } + + // Make a copy of the definition, since we are about to modify it + return buildFeatureGateImplementation({ ...featureDefinitions.get(id) }); + } + + /** + * Constructs feature gate objects for each of the definitions in ``Features.toml``. + * @param {string} testDefinitionsUrl A URL from which definitions can be fetched. Only use this in tests. + */ + static async all(testDefinitionsUrl = undefined) { + let featureDefinitions; + if (testDefinitionsUrl) { + featureDefinitions = await fetchFeatureDefinitions(testDefinitionsUrl); + } else { + featureDefinitions = await lazy.gFeatureDefinitionsPromise; + } + + let definitions = []; + for (let definition of featureDefinitions.values()) { + // Make a copy of the definition, since we are about to modify it + definitions[definitions.length] = buildFeatureGateImplementation( + Object.assign({}, definition) + ); + } + return definitions; + } + + static async observePrefChangesForCrashReportAnnotation( + testDefinitionsUrl = undefined + ) { + let featureDefinitions = await FeatureGate.all(testDefinitionsUrl); + + for (let definition of featureDefinitions.values()) { + FeatureGate.addObserver( + definition.id, + featureGatePrefObserver, + testDefinitionsUrl + ); + } + } + + static async annotateCrashReporter() { + if (!Services.appinfo.crashReporterEnabled) { + return; + } + let features = await FeatureGate.all(); + let enabledFeatures = []; + for (let feature of features) { + if (await feature.getValue()) { + enabledFeatures.push(feature.preference); + } + } + Services.appinfo.annotateCrashReport( + "ExperimentalFeatures", + enabledFeatures.join(",") + ); + } + + /** + * 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(); + } + + static targetingFacts = new Map([ + [ + "release", + AppConstants.MOZ_UPDATE_CHANNEL === "release" || AppConstants.IS_ESR, + ], + ["beta", AppConstants.MOZ_UPDATE_CHANNEL === "beta"], + ["early_beta_or_earlier", AppConstants.EARLY_BETA_OR_EARLIER], + ["dev-edition", AppConstants.MOZ_DEV_EDITION], + ["nightly", AppConstants.NIGHTLY_BUILD], + ["win", AppConstants.platform === "win"], + ["mac", AppConstants.platform === "macosx"], + ["linux", AppConstants.platform === "linux"], + ["android", AppConstants.platform === "android"], + ["thunderbird", AppConstants.MOZ_APP_NAME === "thunderbird"], + ]); + + /** + * 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 + * Calling the function as + * + * evaluateTargetedValue({"default": false, "nightly,linux": true}) + * + * would return true on Nightly on Linux, and false otherwise. + * + * @param {Object} targetedValue + * An object mapping string conditions to values. The conditions are comma + * separated values specified in `targetingFacts`. A condition "default" is + * required, as the fallback valued. All conditions must be true. + * + * If multiple values have conditions that match, then an arbitrary one will + * be returned. Specifically, the one returned first in an `Object.entries` + * iteration of the targetedValue. + * + * @param {Map} [targetingFacts] + * A map of target facts to use. Defaults to facts about the current browser. + * + * @param {boolean} [options.mergeFactsWithDefault] + * If set to true, the value passed for `targetingFacts` will be merged with + * the default facts. + * + * @returns A value from `targetedValue`. + */ + static evaluateTargetedValue( + targetedValue, + targetingFacts = FeatureGate.targetingFacts, + { mergeFactsWithDefault = false } = {} + ) { + if (!Object.hasOwnProperty.call(targetedValue, "default")) { + throw new Error( + `Targeted value ${JSON.stringify(targetedValue)} has no default key` + ); + } + + if (mergeFactsWithDefault) { + const combinedFacts = new Map(FeatureGate.targetingFacts); + for (const [key, value] of targetingFacts.entries()) { + combinedFacts.set(key, value); + } + targetingFacts = combinedFacts; + } + + 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; + } +} diff --git a/toolkit/components/featuregates/FeatureGateImplementation.sys.mjs b/toolkit/components/featuregates/FeatureGateImplementation.sys.mjs new file mode 100644 index 0000000000..e54ac139cb --- /dev/null +++ b/toolkit/components/featuregates/FeatureGateImplementation.sys.mjs @@ -0,0 +1,294 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FeatureGate: "resource://featuregates/FeatureGate.sys.mjs", +}); + +/** An individual feature gate that can be re-used for more advanced usage. */ +export 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 {string} definition.descriptionLinks + * @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(); + } + + // 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 Fluent string ID that will resolve to some text to identify this feature to users. + * @type string + */ + get title() { + return this._definition.title; + } + + /** + * A Fluent string ID that will resolve to a longer string to show to users that explains the feature. + * @type string + */ + get description() { + return this._definition.description; + } + + get descriptionLinks() { + return this._definition.descriptionLinks; + } + + /** + * 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; + } + + /** The default value before any targeting evaluation. */ + get defaultValueOriginalValue() { + // This will probably be overwritten by the loader, but if not provide a default. + return ( + this._definition.defaultValueOriginalValue || { + default: this._definition.defaultValue, + } + ); + } + + /** + * Check what the default value of this feature gate would be on another + * browser with different facts, such as on another platform. + * + * @param {Map} extraFacts + * A `Map` of hypothetical facts to consider, such as {'windows': true} to + * check what the value of this feature would be on Windows. + */ + defaultValueWith(extraFacts) { + return lazy.FeatureGate.evaluateTargetedValue( + this.defaultValueOriginalValue, + extraFacts, + { mergeFactsWithDefault: true } + ); + } + + /** + * 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; + } + + /** The isPublic before any targeting evaluation. */ + get isPublicOriginalValue() { + // This will probably be overwritten by the loader, but if not provide a default. + return ( + this._definition.isPublicOriginalValue || { + default: this._definition.isPublic, + } + ); + } + + /** + * Check if this feature is available on another browser with different + * facts, such as on another platform. + * + * @param {Map} extraFacts + * A `Map` of hypothetical facts to consider, such as {'windows': true} to + * check if this feature would be available on Windows. + */ + isPublicWith(extraFacts) { + return lazy.FeatureGate.evaluateTargetedValue( + this.isPublicOriginalValue, + extraFacts, + { mergeFactsWithDefault: true } + ); + } + + /** + * 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) { + console.error(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 { + console.error( + new Error(`Unexpected event observed: ${aSubject}, ${aTopic}, ${aData}`) + ); + } + } +} diff --git a/toolkit/components/featuregates/Features.toml b/toolkit/components/featuregates/Features.toml new file mode 100644 index 0000000000..a8e04ac0a8 --- /dev/null +++ b/toolkit/components/featuregates/Features.toml @@ -0,0 +1,93 @@ +[css-masonry] +title = "experimental-features-css-masonry2" +description = "experimental-features-css-masonry-description" +description-links = {explainer = "https://github.com/w3c/csswg-drafts/blob/master/css-grid-2/MASONRY-EXPLAINER.md", w3c-issue = "https://github.com/w3c/csswg-drafts/issues/4650", bug = "https://bugzilla.mozilla.org/show_bug.cgi?id=1607439"} +restart-required = false +preference = "layout.css.grid-template-masonry-value.enabled" +type = "boolean" +bug-numbers = [1607954] +is-public = true +default-value = {default = false, nightly = true, thunderbird = true} + +[web-api-webgpu] +title = "experimental-features-web-gpu2" +description = "experimental-features-web-gpu-description3" +description-links = {wikipedia-webgpu = "https://en.wikipedia.org/wiki/WebGPU", wikipedia-gpu = "https://en.wikipedia.org/wiki/Graphics_processing_unit", spec = "https://gpuweb.github.io/gpuweb/", bugzilla = "https://bugzilla.mozilla.org/show_bug.cgi?id=1616739"} +restart-required = false +preference = "dom.webgpu.enabled" +type = "boolean" +bug-numbers = [1616739] +is-public = {default = false, nightly = true} +default-value = {default = false, nightly = true} + +[media-jxl] +title = "experimental-features-media-jxl" +description = "experimental-features-media-jxl-description" +description-links = {bugzilla = "https://bugzilla.mozilla.org/show_bug.cgi?id=1539075"} +restart-required = false +preference = "image.jxl.enabled" +type = "boolean" +bug-numbers = [1539075] +is-public = {default = false, nightly = true} +default-value = false + +[devtools-compatibility-panel] +title = "experimental-features-devtools-compatibility-panel" +description = "experimental-features-devtools-compatibility-panel-description" +description-links = {bugzilla = "https://bugzilla.mozilla.org/show_bug.cgi?id=1584464"} +restart-required = false +preference = "devtools.inspector.compatibility.enabled" +type = "boolean" +bug-numbers = [1605154, 1605153] +is-public = true +default-value = true + +[cookie-samesite-none-requires-secure] +title = "experimental-features-cookie-samesite-none-requires-secure2" +description = "experimental-features-cookie-samesite-none-requires-secure2-description" +restart-required = false +preference = "network.cookie.sameSite.noneRequiresSecure" +type = "boolean" +bug-numbers = [1618610, 1617609] +is-public = true +default-value = {default = false, early_beta_or_earlier = true} + +[abouthome-startup-cache] +title = "experimental-features-abouthome-startup-cache" +description = "experimental-features-abouthome-startup-cache-description" +restart-required = true +preference = "browser.startup.homepage.abouthome_cache.enabled" +type = "boolean" +bug-numbers = [1614351] +is-public = true +default-value = true + +[devtools-serviceworker-debugger-support] +title = "experimental-features-devtools-serviceworker-debugger-support" +description = "experimental-features-devtools-serviceworker-debugger-support-description" +restart-required = true +preference = "devtools.debugger.features.windowless-service-workers" +type = "boolean" +bug-numbers = [1651607] +is-public = true +default-value = false + +[webrtc-global-mute-toggles] +title = "experimental-features-webrtc-global-mute-toggles" +description = "experimental-features-webrtc-global-mute-toggles-description" +restart-required = false +preference = "privacy.webrtc.globalMuteToggles" +type = "boolean" +bug-numbers = [1643027] +is-public = true +default-value = false + +[url-bar-ime-search] +title = "experimental-features-ime-search" +description = "experimental-features-ime-search-description" +restart-required = false +preference = "browser.urlbar.keepPanelOpenDuringImeComposition" +type = "boolean" +bug-numbers = [1673971] +is-public = true +default-value = false diff --git a/toolkit/components/featuregates/docs/index.rst b/toolkit/components/featuregates/docs/index.rst new file mode 100644 index 0000000000..8a6f990de9 --- /dev/null +++ b/toolkit/components/featuregates/docs/index.rst @@ -0,0 +1,179 @@ +.. _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 references to title and description strings (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 = "experimental-features-demo-feature" + description = "experimental-features-demo-feature-description" + restart-required = false + bug-numbers = [1479127] + type = boolean + is-public = {default = false, nightly = true} + default-value = {default = false, nightly = true} + +The references defined in the `title` and `description` values point to strings +stored in ``toolkit/locales/en-US/toolkit/featuregates/features.ftl``. The `title` +string should specify the user-facing string as the `label` attribute. + +.. _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. The string ID of the human readable name for the feature, meant to be shown to + users. Should fit onto a single line. The actual string should be defined in + ``toolkit/locales/en-US/toolkit/featuregates/features.ftl`` with the user-facing value + defined as the `label` attribute of the string. + +description + Required. The string ID of the human readable description for the feature, meant to be shown to + users. Should be at most a paragraph. The actual string should be defined in + ``toolkit/locales/en-US/toolkit/featuregates/features.ftl``. + +description-links + Optional. A dictionary of key-value pairs that are referenced in the description. The key + name must appear in the description localization text as + <a data-l10n-name="key-name">. For example in Features.toml: + +.. code-block:: toml + + [demo-feature] + title = "experimental-features-demo-feature" + description = "experimental-features-demo-feature-description" + description-links = {exampleCom = "https://example.com", exampleOrg = "https://example.org"} + restart-required = false + bug-numbers = [1479127] + type = boolean + is-public = {default = false, nightly = true} + default-value = {default = false, nightly = true} + +and in features.ftl: + +.. code-block:: fluent + + experimental-features-demo-feature = + .label = Example Demo Feature + experimental-features-demo-feature-description = Example demo feature that can point to <a data-l10n-name="exampleCom">.com</a> links and <a data-l10n-name="exampleOrg">.org</a> links. + +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. diff --git a/toolkit/components/featuregates/gen_feature_definitions.py b/toolkit/components/featuregates/gen_feature_definitions.py new file mode 100755 index 0000000000..14cdad3637 --- /dev/null +++ b/toolkit/components/featuregates/gen_feature_definitions.py @@ -0,0 +1,216 @@ +#!/usr/bin/env 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/. + +import json +import re +import sys +import toml +import voluptuous +import voluptuous.humanize +from voluptuous import Schema, Optional, Any, All, Required, Length, Range, Msg, Match + + +Text = Any(str, bytes) + + +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), + Optional("description-links"): dict, + }, + } +) + + +EXIT_OK = 0 +EXIT_ERROR = 1 + + +def main(output, *filenames): + features = {} + errors = False + try: + features = process_files(filenames) + json.dump(features, output, sort_keys=True) + 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 "{}":\n'.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__() + with_comma = original[:-1] + # python 2 adds a trailing comma and python 3 does not, so we need to conditionally reinclude it + if len(with_comma) > 0 and with_comma[-1] != ",": + with_comma = with_comma + "," + return with_comma + " filename={!r})".format(self.filename) + + +def process_files(filenames): + features = {} + errors = [] + + for filename in filenames: + try: + with open(filename, "r") as f: + feature_data = toml.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, + toml.TomlDecodeError, + ) as e: + # Wrap errors in enough information to know which file they came from + errors.append(FeatureGateException(e, filename)) + + 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"])) + # set default value to None so that we can test for perferences where we forgot to set the default value + feature.setdefault("defaultValue", None) + 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) + ) + + 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", + "early_beta_or_earlier", + "beta", + "release", + "dev-edition", + "esr", + "thunderbird", + } + ) + + 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:])) diff --git a/toolkit/components/featuregates/jar.mn b/toolkit/components/featuregates/jar.mn new file mode 100644 index 0000000000..419d7d99f4 --- /dev/null +++ b/toolkit/components/featuregates/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: +% resource featuregates %featuregates/ + featuregates/FeatureGate.sys.mjs (./FeatureGate.sys.mjs) + featuregates/FeatureGateImplementation.sys.mjs (./FeatureGateImplementation.sys.mjs) + featuregates/feature_definitions.json (./feature_definitions.json) diff --git a/toolkit/components/featuregates/moz.build b/toolkit/components/featuregates/moz.build new file mode 100644 index 0000000000..e76e874b8a --- /dev/null +++ b/toolkit/components/featuregates/moz.build @@ -0,0 +1,21 @@ +# -*- 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", "FeatureGate") + +SPHINX_TREES["featuregates"] = "docs" + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] +PYTHON_UNITTEST_MANIFESTS += ["test/python/python.ini"] + +JAR_MANIFESTS += ["jar.mn"] + +GeneratedFile( + "feature_definitions.json", + script="gen_feature_definitions.py", + inputs=["Features.toml"], +) diff --git a/toolkit/components/featuregates/test/python/data/empty_feature.toml b/toolkit/components/featuregates/test/python/data/empty_feature.toml new file mode 100644 index 0000000000..54177fa0e9 --- /dev/null +++ b/toolkit/components/featuregates/test/python/data/empty_feature.toml @@ -0,0 +1 @@ +[empty-feature]
\ No newline at end of file diff --git a/toolkit/components/featuregates/test/python/data/good.toml b/toolkit/components/featuregates/test/python/data/good.toml new file mode 100644 index 0000000000..22a392221d --- /dev/null +++ b/toolkit/components/featuregates/test/python/data/good.toml @@ -0,0 +1,16 @@ +[demo-feature] +title = "Demo Feature" +description = "A no-op feature to demo the feature gate system." +restart-required = false +preference = "foo.bar.baz" +type = "boolean" +bug-numbers = [1479127] +is-public = true +default-value = false + +[minimal-feature] +title = "Minimal Feature" +description = "The smallest feature that is valid" +restart-required = true +type = "boolean" +bug-numbers = [1479127]
\ No newline at end of file diff --git a/toolkit/components/featuregates/test/python/data/invalid_toml.toml b/toolkit/components/featuregates/test/python/data/invalid_toml.toml new file mode 100644 index 0000000000..d4a8001e58 --- /dev/null +++ b/toolkit/components/featuregates/test/python/data/invalid_toml.toml @@ -0,0 +1 @@ +this: is: not: valid: toml diff --git a/toolkit/components/featuregates/test/python/python.ini b/toolkit/components/featuregates/test/python/python.ini new file mode 100644 index 0000000000..4a7f0cdc1b --- /dev/null +++ b/toolkit/components/featuregates/test/python/python.ini @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = featuregates + +[test_gen_feature_definitions.py] diff --git a/toolkit/components/featuregates/test/python/test_gen_feature_definitions.py b/toolkit/components/featuregates/test/python/test_gen_feature_definitions.py new file mode 100644 index 0000000000..0967e38de8 --- /dev/null +++ b/toolkit/components/featuregates/test/python/test_gen_feature_definitions.py @@ -0,0 +1,306 @@ +import json +import sys +import unittest +from os import path +from textwrap import dedent + +import mozunit +import toml +import voluptuous +from io 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.txt":\n oops' + + def test_repr_no_file(self): + error = FeatureGateException("oops") + assert repr(error) == "FeatureGateException('oops', filename=None)" + + def test_repr_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": None}, + }, + } + + 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]) == FeatureGateException + + 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": None} + + 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": None}, + }, + } + + 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:]) diff --git a/toolkit/components/featuregates/test/unit/head.js b/toolkit/components/featuregates/test/unit/head.js new file mode 100644 index 0000000000..bd90d22f03 --- /dev/null +++ b/toolkit/components/featuregates/test/unit/head.js @@ -0,0 +1,3 @@ +var { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); diff --git a/toolkit/components/featuregates/test/unit/test_FeatureGate.js b/toolkit/components/featuregates/test/unit/test_FeatureGate.js new file mode 100644 index 0000000000..b0aed54e8e --- /dev/null +++ b/toolkit/components/featuregates/test/unit/test_FeatureGate.js @@ -0,0 +1,446 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FeatureGate } = ChromeUtils.importESModule( + "resource://featuregates/FeatureGate.sys.mjs" +); +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +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, + "test-fact": !definition.isPublic, + }; + definition.defaultValue = { + default: definition.defaultValue, + "test-fact": !definition.defaultValue, + }; + this.definitions[definition.id] = definition; + return definition; + } +} + +// ============================================================================ +add_task(async function testReadAll() { + const server = new DefinitionServer(); + let ids = ["test-featureA", "test-featureB", "test-featureC"]; + for (let id of ids) { + server.addDefinition({ id }); + } + let sortedIds = ids.sort(); + const features = await FeatureGate.all(server.definitionsUrl); + for (let feature of features) { + equal( + feature.id, + sortedIds.shift(), + "Features are returned in order of definition" + ); + } + equal(sortedIds.length, 0, "All features are returned when calling all()"); +}); + +// 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.defaultValueWith(new Map()), + definition.defaultValue.default, + "An empty set of extra facts results in the same value" + ); + equal( + feature.defaultValueWith(new Map([["test-fact", true]])), + !definition.defaultValue.default, + "Including an extra fact can change the value" + ); + + equal( + feature.isPublic, + definition.isPublic.default, + "isPublic should be processed as a targeted value" + ); + equal( + feature.isPublicWith(new Map()), + definition.isPublic.default, + "An empty set of extra facts results in the same value" + ); + equal( + feature.isPublicWith(new Map([["test-fact", true]])), + !definition.isPublic.default, + "Including an extra fact can change the value" + ); + + // cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.feature"); +}); + +// Targeted values should return the correct value +add_task(async function testTargetedValues() { + const targetingFacts = new Map( + Object.entries({ true1: true, true2: true, false1: false, false2: false }) + ); + + Assert.equal( + FeatureGate.evaluateTargetedValue({ default: "foo" }, targetingFacts), + "foo", + "A lone default value should be returned" + ); + Assert.equal( + FeatureGate.evaluateTargetedValue( + { default: "foo", true1: "bar" }, + targetingFacts + ), + "bar", + "A true target should override the default" + ); + Assert.equal( + FeatureGate.evaluateTargetedValue( + { default: "foo", false1: "bar" }, + targetingFacts + ), + "foo", + "A false target should not overrides the default" + ); + Assert.equal( + FeatureGate.evaluateTargetedValue( + { default: "foo", "true1,true2": "bar" }, + targetingFacts + ), + "bar", + "A compound target of two true targets should override the default" + ); + Assert.equal( + FeatureGate.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( + FeatureGate.evaluateTargetedValue( + { default: "foo", "false1,false2": "bar" }, + targetingFacts + ), + "foo", + "A compound target of two false targets should not override the default" + ); + Assert.equal( + FeatureGate.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); +}); + +if (AppConstants.platform != "android") { + // All preferences should have default values. + add_task(async function testAllHaveDefault() { + const featuresList = await FeatureGate.all(); + for (let feature of featuresList) { + notEqual( + typeof feature.defaultValue, + "undefined", + `Feature ${feature.id} should have a defined default value!` + ); + notEqual( + feature.defaultValue, + null, + `Feature ${feature.id} should have a non-null default value!` + ); + } + }); + + // All preference defaults should match service pref defaults + add_task(async function testAllDefaultsMatchSettings() { + const featuresList = await FeatureGate.all(); + for (let feature of featuresList) { + let value = Services.prefs + .getDefaultBranch("") + .getBoolPref(feature.preference); + equal( + feature.defaultValue, + value, + `Feature ${feature.preference} should match runtime value.` + ); + } + }); +} diff --git a/toolkit/components/featuregates/test/unit/test_FeatureGateImplementation.js b/toolkit/components/featuregates/test/unit/test_FeatureGateImplementation.js new file mode 100644 index 0000000000..ac844b4880 --- /dev/null +++ b/toolkit/components/featuregates/test/unit/test_FeatureGateImplementation.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FeatureGateImplementation } = ChromeUtils.importESModule( + "resource://featuregates/FeatureGateImplementation.sys.mjs" +); + +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); +} + +// 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.getPrefType(preference), + Services.prefs.PREF_INVALID, + "Instantiating a feature gate should not set its default value" + ); + equal( + await feature.getValue(), + false, + "getValue() should return the feature gate's default" + ); + + 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); +}); diff --git a/toolkit/components/featuregates/test/unit/xpcshell.ini b/toolkit/components/featuregates/test/unit/xpcshell.ini new file mode 100644 index 0000000000..dc6cb296bd --- /dev/null +++ b/toolkit/components/featuregates/test/unit/xpcshell.ini @@ -0,0 +1,11 @@ +[DEFAULT] +head = head.js +tags = featuregates +firefox-appdir = browser + +[test_FeatureGate.js] +# Ignore platforms which the use the update channel 'default' on non-nightly +# platforms because it gets compared to preference values guarded by variables +# like RELEASE_OR_BETA which are set based on the build channel. +skip-if = !nightly_build && (asan || debug) +[test_FeatureGateImplementation.js] |