summaryrefslogtreecommitdiffstats
path: root/toolkit/components/featuregates
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/featuregates
parentInitial commit. (diff)
downloadthunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz
thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/featuregates')
-rw-r--r--toolkit/components/featuregates/FeatureGate.sys.mjs288
-rw-r--r--toolkit/components/featuregates/FeatureGateImplementation.sys.mjs294
-rw-r--r--toolkit/components/featuregates/Features.toml93
-rw-r--r--toolkit/components/featuregates/docs/index.rst179
-rwxr-xr-xtoolkit/components/featuregates/gen_feature_definitions.py216
-rw-r--r--toolkit/components/featuregates/jar.mn9
-rw-r--r--toolkit/components/featuregates/moz.build21
-rw-r--r--toolkit/components/featuregates/test/python/data/empty_feature.toml1
-rw-r--r--toolkit/components/featuregates/test/python/data/good.toml16
-rw-r--r--toolkit/components/featuregates/test/python/data/invalid_toml.toml1
-rw-r--r--toolkit/components/featuregates/test/python/python.ini4
-rw-r--r--toolkit/components/featuregates/test/python/test_gen_feature_definitions.py306
-rw-r--r--toolkit/components/featuregates/test/unit/head.js3
-rw-r--r--toolkit/components/featuregates/test/unit/test_FeatureGate.js446
-rw-r--r--toolkit/components/featuregates/test/unit/test_FeatureGateImplementation.js141
-rw-r--r--toolkit/components/featuregates/test/unit/xpcshell.ini11
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]