Bug 1942694 - Remove toolkit/components/featuregates r=settings-reviewers,firefox-desktop-core-reviewers ,frontend-codestyle-reviewers,Gijs,android-reviewers,Roger
Differential Revision: https://phabricator.services.mozilla.com/D239672
This commit is contained in:
@@ -362,7 +362,6 @@ const rollouts = [
|
||||
"toolkit/components/crashmonitor/CrashMonitor.sys.mjs",
|
||||
"toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs",
|
||||
"toolkit/components/downloads/**",
|
||||
"toolkit/components/featuregates/FeatureGate*.*",
|
||||
"toolkit/components/forgetaboutsite/test/unit/test_removeDataFromDomain.js",
|
||||
"toolkit/components/glean/tests/browser/**",
|
||||
"toolkit/components/kvstore/kvstore.sys.mjs",
|
||||
@@ -513,7 +512,6 @@ const rollouts = [
|
||||
"toolkit/components/downloads/**",
|
||||
"toolkit/components/enterprisepolicies/EnterprisePolicies*.sys.mjs",
|
||||
"toolkit/components/extensions/**",
|
||||
"toolkit/components/featuregates/**",
|
||||
"toolkit/components/forgetaboutsite/**",
|
||||
"toolkit/components/formautofill/**",
|
||||
"toolkit/components/glean/tests/browser/**",
|
||||
|
||||
@@ -37,7 +37,6 @@ ChromeUtils.defineESModuleGetters(lazy, {
|
||||
DownloadsViewableInternally:
|
||||
"resource:///modules/DownloadsViewableInternally.sys.mjs",
|
||||
ExtensionsUI: "resource:///modules/ExtensionsUI.sys.mjs",
|
||||
FeatureGate: "resource://featuregates/FeatureGate.sys.mjs",
|
||||
FirefoxBridgeExtensionUtils:
|
||||
"resource:///modules/FirefoxBridgeExtensionUtils.sys.mjs",
|
||||
// FilePickerCrashed is used by the `listeners` object below.
|
||||
@@ -2387,8 +2386,6 @@ BrowserGlue.prototype = {
|
||||
if (AppConstants.MOZ_CRASHREPORTER) {
|
||||
lazy.UnsubmittedCrashHandler.init();
|
||||
lazy.UnsubmittedCrashHandler.scheduleCheckForUnsubmittedCrashReports();
|
||||
lazy.FeatureGate.annotateCrashReporter();
|
||||
lazy.FeatureGate.observePrefChangesForCrashReportAnnotation();
|
||||
}
|
||||
|
||||
if (AppConstants.ASAN_REPORTER) {
|
||||
|
||||
@@ -74,7 +74,6 @@ js_source_path = [
|
||||
"../toolkit/actors",
|
||||
"../toolkit/components/extensions",
|
||||
"../toolkit/components/extensions/parent",
|
||||
"../toolkit/components/featuregates",
|
||||
"../toolkit/components/ml/content/backends/ONNXPipeline.mjs",
|
||||
"../toolkit/mozapps/extensions",
|
||||
"../toolkit/components/prompts/src",
|
||||
|
||||
@@ -822,24 +822,6 @@ memory:
|
||||
|
||||
|
||||
environment:
|
||||
experimental_features:
|
||||
type: string_list
|
||||
description: >
|
||||
Specifies the enabled experimental features from
|
||||
about:preferences#experimental.
|
||||
notification_emails:
|
||||
- crash-reporting-wg@mozilla.org
|
||||
- stability@mozilla.org
|
||||
bugs:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830098
|
||||
data_reviews:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830098
|
||||
data_sensitivity:
|
||||
- technical
|
||||
expires: never
|
||||
send_in_pings:
|
||||
- crash
|
||||
|
||||
headless_mode:
|
||||
type: boolean
|
||||
description: >
|
||||
|
||||
@@ -40,7 +40,6 @@ import mozilla.components.support.ktx.android.content.isMainProcess
|
||||
import mozilla.telemetry.glean.private.BooleanMetricType
|
||||
import mozilla.telemetry.glean.private.ObjectMetricType
|
||||
import mozilla.telemetry.glean.private.QuantityMetricType
|
||||
import mozilla.telemetry.glean.private.StringListMetricType
|
||||
import mozilla.telemetry.glean.private.StringMetricType
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -149,9 +148,6 @@ class GleanCrashReporterService(
|
||||
// Overrides the original `startup` parameter to `Ping` when present
|
||||
GleanCrash.startup.setIfNonNull(extras["StartupCrash"])
|
||||
|
||||
GleanEnvironment.experimentalFeatures.setExperimentalFeaturesIfNonNull(
|
||||
extras["ExperimentalFeatures"],
|
||||
)
|
||||
GleanEnvironment.headlessMode.setIfNonNull(extras["HeadlessMode"])
|
||||
|
||||
GleanMemory.availableCommit.setIfNonNull(extras["AvailablePageFile"])
|
||||
@@ -180,15 +176,6 @@ class GleanCrashReporterService(
|
||||
element?.jsonPrimitive?.long?.let(::set)
|
||||
}
|
||||
|
||||
private fun StringListMetricType.setExperimentalFeaturesIfNonNull(
|
||||
element: JsonElement?,
|
||||
) {
|
||||
element?.let {
|
||||
// Split on commas
|
||||
set(it.jsonPrimitive.content.split(',').filter(String::isNotEmpty))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ObjectMetricType<AsyncShutdownTimeoutObject>.setAsyncShutdownTimeoutIfNonNull(
|
||||
element: JsonElement?,
|
||||
) {
|
||||
|
||||
@@ -639,7 +639,6 @@ class GleanCrashReporterServiceTest {
|
||||
"Version": "123.0.0",
|
||||
"StartupCrash": "1",
|
||||
"TotalPhysicalMemory": 100,
|
||||
"ExperimentalFeatures": "expa,expb",
|
||||
"AsyncShutdownTimeout": "{\"phase\":\"abcd\",\"conditions\":[{\"foo\":\"bar\"}],\"brokenAddBlockers\":[\"foo\"]}",
|
||||
"QuotaManagerShutdownTimeout": "line1\nline2\nline3",
|
||||
"StackTraces": $stackTracesAnnotation
|
||||
@@ -677,10 +676,6 @@ class GleanCrashReporterServiceTest {
|
||||
assertEquals("beta", GleanCrash.appChannel.testGetValue())
|
||||
assertEquals("123.0.0", GleanCrash.appDisplayVersion.testGetValue())
|
||||
assertEquals(100L, GleanMemory.totalPhysical.testGetValue())
|
||||
assertEquals(
|
||||
listOf("expa", "expb"),
|
||||
GleanEnvironment.experimentalFeatures.testGetValue(),
|
||||
)
|
||||
assertEquals(
|
||||
JsonObject(
|
||||
mapOf(
|
||||
|
||||
@@ -63,7 +63,6 @@ exclude = [
|
||||
"testing/runtimes/writeruntimes.py",
|
||||
"testing/tools/iceserver/iceserver.py",
|
||||
"testing/tools/websocketprocessbridge/websocketprocessbridge.py",
|
||||
"toolkit/components/featuregates",
|
||||
"toolkit/content/tests/chrome/file_about_networking_wsh.py",
|
||||
"toolkit/library/build/dependentlibs.py",
|
||||
"toolkit/mozapps",
|
||||
|
||||
@@ -534,28 +534,6 @@ condprof:
|
||||
- 'testing/condprofile/condprof**'
|
||||
- 'testing/condprofile/setup.py'
|
||||
|
||||
featuregates:
|
||||
description: featuregates Python unit tests
|
||||
platform:
|
||||
- linux1804-64/opt
|
||||
- windows11-64/opt
|
||||
python-version: [3]
|
||||
treeherder:
|
||||
symbol: fg
|
||||
run:
|
||||
using: python-test
|
||||
subsuite: featuregates
|
||||
fetches:
|
||||
toolchain:
|
||||
by-platform:
|
||||
linux1804-64/opt:
|
||||
- linux64-node
|
||||
windows11-64/opt:
|
||||
- win64-node
|
||||
when:
|
||||
files-changed:
|
||||
- 'toolkit/components/featuregates/**'
|
||||
|
||||
skip-fails:
|
||||
description: testing/skip-fails unit tests
|
||||
always-target: false
|
||||
|
||||
@@ -869,7 +869,6 @@ CrashManager.prototype = Object.freeze({
|
||||
user32LoadedBefore: t(bool, "User32BeforeBlocklist"),
|
||||
},
|
||||
environment: {
|
||||
experimentalFeatures: t(comma_list, cap),
|
||||
headlessMode: t(bool, cap),
|
||||
nimbusEnrollments: t(comma_list, cap),
|
||||
uptime: t(seconds, "UptimeTS"),
|
||||
|
||||
@@ -832,24 +832,6 @@ environment:
|
||||
send_in_pings:
|
||||
- crash
|
||||
|
||||
experimental_features:
|
||||
type: string_list
|
||||
description: >
|
||||
Specifies the enabled experimental features from
|
||||
about:preferences#experimental.
|
||||
notification_emails:
|
||||
- crash-reporting-wg@mozilla.org
|
||||
- stability@mozilla.org
|
||||
bugs:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830098
|
||||
data_reviews:
|
||||
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830098
|
||||
data_sensitivity:
|
||||
- technical
|
||||
expires: never
|
||||
send_in_pings:
|
||||
- crash
|
||||
|
||||
headless_mode:
|
||||
type: boolean
|
||||
description: >
|
||||
|
||||
@@ -975,10 +975,6 @@ add_task(async function test_glean_crash_ping() {
|
||||
]);
|
||||
Assert.equal(Glean.dllBlocklist.initFailed.testGetValue(), true);
|
||||
Assert.equal(Glean.dllBlocklist.user32LoadedBefore.testGetValue(), true);
|
||||
Assert.deepEqual(Glean.environment.experimentalFeatures.testGetValue(), [
|
||||
"feature 1",
|
||||
"feature 2",
|
||||
]);
|
||||
Assert.equal(Glean.environment.headlessMode.testGetValue(), true);
|
||||
Assert.deepEqual(Glean.environment.nimbusEnrollments.testGetValue(), [
|
||||
"foo:control",
|
||||
@@ -1022,7 +1018,6 @@ add_task(async function test_glean_crash_ping() {
|
||||
BlockedDllList: "Foo.dll;bar.dll;rawr.dll",
|
||||
BlocklistInitFailed: "1",
|
||||
EventLoopNestingLevel: 5,
|
||||
ExperimentalFeatures: "feature 1,feature 2",
|
||||
FontName: "Helvetica",
|
||||
GPUProcessLaunchCount: 10,
|
||||
HeadlessMode: "1",
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
|
||||
|
||||
const lazy = {};
|
||||
ChromeUtils.defineESModuleGetters(lazy, {
|
||||
// This is an unfortunate exception where we depend on ASRouter because
|
||||
// Nimbus has this dependency.
|
||||
// This implementation is written in a way where it will avoid requiring
|
||||
// this module if it's not available.
|
||||
ASRouterTargeting:
|
||||
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
|
||||
"resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
|
||||
FeatureGateImplementation:
|
||||
"resource://featuregates/FeatureGateImplementation.sys.mjs",
|
||||
ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
|
||||
TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
|
||||
});
|
||||
|
||||
ChromeUtils.defineLazyGetter(lazy, "gFeatureDefinitionsPromise", async () => {
|
||||
const url = "resource://featuregates/feature_definitions.json";
|
||||
return fetchFeatureDefinitions(url);
|
||||
});
|
||||
|
||||
const kCustomTargeting = {
|
||||
// For default values, although something like `channel == 'nightly'` kinda
|
||||
// works, local builds don't have that update channel set in that way so it
|
||||
// doesn't, and then tests fail because the defaults for the FeatureGate
|
||||
// do not match the default value in the prefs code.
|
||||
// We may in future want other things from AppConstants here, too.
|
||||
nightly_build: AppConstants.NIGHTLY_BUILD,
|
||||
thunderbird: AppConstants.MOZ_APP_NAME == "thunderbird",
|
||||
};
|
||||
|
||||
ChromeUtils.defineLazyGetter(lazy, "defaultContexts", () => {
|
||||
let ASRouterEnv = {};
|
||||
try {
|
||||
ASRouterEnv = lazy.ASRouterTargeting.Environment;
|
||||
} catch (ex) {
|
||||
// No ASRouter; just keep going.
|
||||
}
|
||||
return [
|
||||
kCustomTargeting,
|
||||
lazy.ExperimentManager.createTargetingContext(),
|
||||
ASRouterEnv,
|
||||
];
|
||||
});
|
||||
|
||||
function getCombinedContext(...contexts) {
|
||||
let combined = lazy.TargetingContext.combineContexts(
|
||||
...lazy.defaultContexts,
|
||||
...contexts
|
||||
);
|
||||
return new lazy.TargetingContext(combined, {
|
||||
source: "featuregate",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFeatureDefinitions(url) {
|
||||
const res = await fetch(url);
|
||||
let definitionsJson = await res.json();
|
||||
return new Map(Object.entries(definitionsJson));
|
||||
}
|
||||
|
||||
async function buildFeatureGateImplementation(definition) {
|
||||
const targetValueKeys = ["defaultValue", "isPublic"];
|
||||
for (const key of targetValueKeys) {
|
||||
definition[key] = await FeatureGate.evaluateJexlValue(
|
||||
definition[key + "Jexl"]
|
||||
);
|
||||
}
|
||||
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] = await 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a jexl expression and evaluate it against the standard Nimbus
|
||||
* context, extended with some additional properties defined in
|
||||
* kCustomTargeting.
|
||||
*
|
||||
* @param {String} jexlExpression The expression to evaluate.
|
||||
* @param {Object[]?} additionalContexts Any additional context properties
|
||||
* that should be taken into account.
|
||||
*
|
||||
* @returns {Promise<boolean>} Resolves to either true or false if successful,
|
||||
* or null if there was some problem with the jexl expression (which
|
||||
* will also log an error to the console).
|
||||
*/
|
||||
static async evaluateJexlValue(jexlExpression, ...additionalContexts) {
|
||||
let result = null;
|
||||
let context = getCombinedContext(...additionalContexts);
|
||||
try {
|
||||
result = !!(await context.evalWithDefault(jexlExpression));
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
/* 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/. */
|
||||
|
||||
/** 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's group to users.
|
||||
* @type string
|
||||
*/
|
||||
get group() {
|
||||
return this._definition.group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* If this feature should be exposed to users in an advanced settings panel
|
||||
* for this build of Firefox.
|
||||
*
|
||||
* @type boolean
|
||||
*/
|
||||
get isPublic() {
|
||||
return this._definition.isPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug numbers associated with this feature.
|
||||
* @type Array<number>
|
||||
*/
|
||||
get bugNumbers() {
|
||||
return this._definition.bugNumbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of this feature gate. Implementors should avoid
|
||||
* storing the result to avoid missing changes to the feature's value.
|
||||
* Consider using :func:`addObserver` if it is necessary to store the value
|
||||
* of the feature.
|
||||
*
|
||||
* @async
|
||||
* @returns {Promise<boolean>} A promise for the value associated with this feature.
|
||||
*/
|
||||
// Note that this is async for potential future use of a storage backend besides preferences.
|
||||
async getValue() {
|
||||
return Services.prefs.getBoolPref(this.preference, this.defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias of `getValue` for boolean typed feature gates.
|
||||
*
|
||||
* @async
|
||||
* @returns {Promise<boolean>} A promise for the value associated with this feature.
|
||||
* @throws {Error} If the feature is not a boolean.
|
||||
*/
|
||||
// Note that this is async for potential future use of a storage backend besides preferences.
|
||||
async isEnabled() {
|
||||
if (this.type !== "boolean") {
|
||||
throw new Error(
|
||||
`Tried to call isEnabled when type is not boolean (it is ${this.type})`
|
||||
);
|
||||
}
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an observer for changes to this feature. When the observer is added,
|
||||
* `onChange` will asynchronously be called with the current value of the
|
||||
* preference. If the feature is of type boolean and currently enabled,
|
||||
* `onEnable` will additionally be called.
|
||||
*
|
||||
* @param {object} observer Functions to be called when the feature changes.
|
||||
* All observer functions are optional.
|
||||
* @param {Function()} [observer.onEnable] Called when the feature becomes enabled.
|
||||
* @param {Function()} [observer.onDisable] Called when the feature becomes disabled.
|
||||
* @param {Function(newValue: boolean)} [observer.onChange] Called when the
|
||||
* feature's state changes to any value. The new value will be passed to the
|
||||
* function.
|
||||
* @returns {Promise<boolean>} The current value of the feature.
|
||||
*/
|
||||
async addObserver(observer) {
|
||||
if (this._observers.size === 0) {
|
||||
Services.prefs.addObserver(this.preference, this);
|
||||
}
|
||||
|
||||
this._observers.add(observer);
|
||||
|
||||
if (this.type === "boolean" && (await this.isEnabled())) {
|
||||
this._callObserverMethod(observer, "onEnable");
|
||||
}
|
||||
// onDisable should not be called, because features should be assumed
|
||||
// disabled until onEnabled is called for the first time.
|
||||
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an observer of changes from this feature
|
||||
* @param observer The observer that was passed to addObserver to remove.
|
||||
*/
|
||||
removeObserver(observer) {
|
||||
this._observers.delete(observer);
|
||||
if (this._observers.size === 0) {
|
||||
Services.prefs.removeObserver(this.preference, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all observers from this instance of the feature gate.
|
||||
*/
|
||||
removeAllObservers() {
|
||||
if (this._observers.size > 0) {
|
||||
this._observers.clear();
|
||||
Services.prefs.removeObserver(this.preference, this);
|
||||
}
|
||||
}
|
||||
|
||||
_callObserverMethod(observer, method, ...args) {
|
||||
if (method in observer) {
|
||||
try {
|
||||
observer[method](...args);
|
||||
} catch (err) {
|
||||
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}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
# is-public-jexl and default-value-jexl are both JEXL expressions.
|
||||
# They are evaluated using the Experimenter environment, plus some
|
||||
# additional variables defined by ASRouter and FeatureGate.
|
||||
# The result of evaluating is-public-jexl is exposed as `isPublic`
|
||||
# and controls whether the feature is shown to the user in e.g.
|
||||
# the relevant pane in Firefox's settings.
|
||||
# The result of evaluating default-value-jexl is exposed as
|
||||
# `defaultValue` and should match the default value of the pref
|
||||
# as set in the relevant .js prefs file or StaticPrefList.yaml.
|
||||
#
|
||||
# NOTE ON ORDERING
|
||||
# The ordering of the groups and controls in about:preferences is
|
||||
# determined by the ordering in this file. Groups will be ordered
|
||||
# based on the order of appearance in this file. Settings within
|
||||
# a group will also be determined by their order in this file.
|
||||
|
||||
[auto-pip]
|
||||
group = "experimental-features-group-customize-browsing"
|
||||
title = "experimental-features-auto-pip"
|
||||
description = "experimental-features-auto-pip-description"
|
||||
restart-required = false
|
||||
preference = "media.videocontrols.picture-in-picture.enable-when-switching-tabs.enabled"
|
||||
type = "boolean"
|
||||
bug-numbers = [1647800]
|
||||
is-public-jexl = "true"
|
||||
default-value-jexl = "false"
|
||||
|
||||
[url-bar-ime-search]
|
||||
group = "experimental-features-group-customize-browsing"
|
||||
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-jexl = "true"
|
||||
default-value-jexl = "false"
|
||||
|
||||
[media-jxl]
|
||||
group = "experimental-features-group-webpage-display"
|
||||
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-jexl = "nightly_build"
|
||||
default-value-jexl = "false"
|
||||
@@ -1,179 +0,0 @@
|
||||
.. _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.
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/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("group"): All(Text, Length(min=1)),
|
||||
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-jexl"): Text,
|
||||
Optional("is-public-jexl"): Text,
|
||||
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)
|
||||
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 preferences where we forgot to set the default value
|
||||
feature.setdefault("defaultValueJexl", None)
|
||||
elif "preference" not in feature:
|
||||
raise FeatureGateException(
|
||||
"Features of type {} must specify an explicit preference name".format(
|
||||
feature["type"]
|
||||
)
|
||||
)
|
||||
|
||||
feature.setdefault("isPublicJexl", "false")
|
||||
return feature
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.stdout, *sys.argv[1:]))
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# -*- 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.toml"]
|
||||
|
||||
JAR_MANIFESTS += ["jar.mn"]
|
||||
|
||||
GeneratedFile(
|
||||
"feature_definitions.json",
|
||||
script="gen_feature_definitions.py",
|
||||
inputs=["Features.toml"],
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
[empty-feature]
|
||||
@@ -1,18 +0,0 @@
|
||||
[demo-feature]
|
||||
title = "demo-feature-title"
|
||||
group = "demo-feature-group"
|
||||
description = "demo-feature-description"
|
||||
restart-required = false
|
||||
preference = "foo.bar.baz"
|
||||
type = "boolean"
|
||||
bug-numbers = [1479127]
|
||||
is-public-jexl = "true"
|
||||
default-value-jexl = "false"
|
||||
|
||||
[minimal-feature]
|
||||
title = "minimal-feature-title"
|
||||
group = "minimal-feature-group"
|
||||
description = "minimal-feature-description"
|
||||
restart-required = true
|
||||
type = "boolean"
|
||||
bug-numbers = [1479127]
|
||||
@@ -1 +0,0 @@
|
||||
this: is: not: valid: toml
|
||||
@@ -1,4 +0,0 @@
|
||||
[DEFAULT]
|
||||
subsuite = "featuregates"
|
||||
|
||||
["test_gen_feature_definitions.py"]
|
||||
@@ -1,281 +0,0 @@
|
||||
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_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",
|
||||
"group": "Test Feature Group",
|
||||
"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-title",
|
||||
"group": "demo-feature-group",
|
||||
"description": "demo-feature-description",
|
||||
"restartRequired": False,
|
||||
"preference": "foo.bar.baz",
|
||||
"type": "boolean",
|
||||
"bugNumbers": [1479127],
|
||||
"isPublicJexl": "true",
|
||||
"defaultValueJexl": "false",
|
||||
},
|
||||
"minimal-feature": {
|
||||
"id": "minimal-feature",
|
||||
"title": "minimal-feature-title",
|
||||
"group": "minimal-feature-group",
|
||||
"description": "minimal-feature-description",
|
||||
"restartRequired": True,
|
||||
"preference": "features.minimal-feature.enabled",
|
||||
"type": "boolean",
|
||||
"bugNumbers": [1479127],
|
||||
"isPublicJexl": "false",
|
||||
"defaultValueJexl": 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 "default-value-jexl" not in feature
|
||||
assert "defaultValueJexl" not in feature
|
||||
assert expand_feature(feature)["defaultValueJexl"] == None
|
||||
|
||||
def test_default_value_override_constant(self):
|
||||
feature = minimal_definition(type="boolean", default_value_jexl="true")
|
||||
assert expand_feature(feature)["defaultValueJexl"] == "true"
|
||||
|
||||
def test_default_value_override_configured_value(self):
|
||||
feature = minimal_definition(
|
||||
type="boolean", default_value_jexl="channel == nightly"
|
||||
)
|
||||
assert expand_feature(feature)["defaultValueJexl"] == "channel == nightly"
|
||||
|
||||
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 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 == {
|
||||
"demo-feature": {
|
||||
"id": "demo-feature",
|
||||
"title": "demo-feature-title",
|
||||
"group": "demo-feature-group",
|
||||
"description": "demo-feature-description",
|
||||
"restartRequired": False,
|
||||
"preference": "foo.bar.baz",
|
||||
"type": "boolean",
|
||||
"bugNumbers": [1479127],
|
||||
"isPublicJexl": "true",
|
||||
"defaultValueJexl": "false",
|
||||
},
|
||||
"minimal-feature": {
|
||||
"id": "minimal-feature",
|
||||
"title": "minimal-feature-title",
|
||||
"group": "minimal-feature-group",
|
||||
"description": "minimal-feature-description",
|
||||
"restartRequired": True,
|
||||
"preference": "features.minimal-feature.enabled",
|
||||
"type": "boolean",
|
||||
"bugNumbers": [1479127],
|
||||
"isPublicJexl": "false",
|
||||
"defaultValueJexl": 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:])
|
||||
@@ -1,3 +0,0 @@
|
||||
var { sinon } = ChromeUtils.importESModule(
|
||||
"resource://testing-common/Sinon.sys.mjs"
|
||||
);
|
||||
@@ -1,412 +0,0 @@
|
||||
/* 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.importESModule(
|
||||
"resource://testing-common/httpd.sys.mjs"
|
||||
);
|
||||
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.isPublicJexl = definition.isPublic ? "!testFact" : "testFact";
|
||||
definition.defaultValueJexl = definition.defaultValue
|
||||
? "!testFact"
|
||||
: "testFact";
|
||||
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,
|
||||
false,
|
||||
"defaultValue should be false because testFact was not provided."
|
||||
);
|
||||
|
||||
equal(
|
||||
feature.isPublic,
|
||||
false,
|
||||
"isPublic should be false because testFact was not provided."
|
||||
);
|
||||
|
||||
// cleanup
|
||||
Services.prefs.getDefaultBranch("").deleteBranch("test.feature");
|
||||
});
|
||||
|
||||
// Targeted values should return the correct value
|
||||
add_task(async function testTargetedValues() {
|
||||
const targetingFacts = {
|
||||
true1: true,
|
||||
true2: true,
|
||||
false1: false,
|
||||
false2: false,
|
||||
};
|
||||
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("true1", targetingFacts),
|
||||
true,
|
||||
"A true value should be reflected"
|
||||
);
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("false1", targetingFacts),
|
||||
false,
|
||||
"A false value should be reflected"
|
||||
);
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("false"),
|
||||
false,
|
||||
"Boolean literal false should work"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("true"),
|
||||
true,
|
||||
"Boolean literal true should work"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("false2 || true1", targetingFacts),
|
||||
true,
|
||||
"Compound expressions work."
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("testFact", {}),
|
||||
false,
|
||||
"Non-existing terms in the expression get coerced to bool false."
|
||||
);
|
||||
Assert.equal(
|
||||
await FeatureGate.evaluateJexlValue("testFact", { testFact: true }),
|
||||
true,
|
||||
"Providing testFact=true, the expression returns true."
|
||||
);
|
||||
});
|
||||
|
||||
// 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.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/* 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);
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
[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"]
|
||||
@@ -134,7 +134,7 @@ if CONFIG["MOZ_WIDGET_TOOLKIT"] not in ("android", "windows"):
|
||||
DIRS += ["aboutwebauthn"]
|
||||
|
||||
if CONFIG["MOZ_BUILD_APP"] == "browser":
|
||||
DIRS += ["featuregates", "messaging-system", "normandy"]
|
||||
DIRS += ["messaging-system", "normandy"]
|
||||
|
||||
DIRS += ["nimbus"]
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ Structure:
|
||||
CrashTime: <time>, // Seconds since the Epoch
|
||||
DOMFissionEnabled: "1", // Optional, if set indicates that a Fission window had been opened
|
||||
EventLoopNestingLevel: <levels>, // Optional, present only if >0, indicates the nesting level of the event-loop
|
||||
ExperimentalFeatures: <features>, // Optional, a comma-separated string that specifies the enabled experimental features from about:preferences#experimental
|
||||
FontName: <name>, // Optional, the font family name that is being loaded when the crash occurred
|
||||
GPUProcessLaunchCount: <num>, // Number of times the GPU process was launched
|
||||
HeadlessMode: "1", // Optional, "1" if the app was invoked in headless mode via `--headless ...` or `--backgroundtask ...`
|
||||
@@ -268,3 +267,4 @@ Version History
|
||||
- Firefox 107: Added UtilityActorsName (`bug 1788596 <https://bugzilla.mozilla.org/show_bug.cgi?id=1788596>`_).
|
||||
- Firefox 119: Added WindowsFileDialogErrorCode (`bug 1837079 <https://bugzilla.mozilla.org/show_bug.cgi?id=1837079>`_)
|
||||
- Firefox 137: Added NimbusEnrollments (`bug 1950661 <https://bugzilla.mozilla.org/show_bug.cgi?id=1950661>`_).
|
||||
- Firefox 138: Removed ExperimentalFeatures (`bug 1942694 <https://bugzilla.mozilla.org/show_bug.cgi?id=1942694>`_).
|
||||
|
||||
@@ -381,33 +381,6 @@ var snapshotFormatters = {
|
||||
}
|
||||
},
|
||||
|
||||
async experimentalFeatures(data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
let titleL10nIds = data.map(([titleL10nId]) => titleL10nId);
|
||||
let titleL10nObjects = await document.l10n.formatMessages(titleL10nIds);
|
||||
if (titleL10nObjects.length != data.length) {
|
||||
throw Error("Missing localized title strings in experimental features");
|
||||
}
|
||||
for (let i = 0; i < titleL10nObjects.length; i++) {
|
||||
let localizedTitle = titleL10nObjects[i].attributes.find(
|
||||
a => a.name == "label"
|
||||
).value;
|
||||
data[i] = [localizedTitle, data[i][1], data[i][2]];
|
||||
}
|
||||
|
||||
$.append(
|
||||
$("experimental-features-tbody"),
|
||||
data.map(function ([title, pref, value]) {
|
||||
return $.new("tr", [
|
||||
$.new("td", `${title} (${pref})`, "pref-name"),
|
||||
$.new("td", value, "pref-value"),
|
||||
]);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
environmentVariables(data) {
|
||||
if (!data) {
|
||||
return;
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
<link rel="localization" href="toolkit/about/aboutSupport.ftl"/>
|
||||
<link rel="localization" href="toolkit/global/resetProfile.ftl"/>
|
||||
<link rel="localization" href="toolkit/global/processTypes.ftl"/>
|
||||
#ifndef ANDROID
|
||||
<link rel="localization" href="toolkit/featuregates/features.ftl"/>
|
||||
#endif
|
||||
</head>
|
||||
|
||||
<body class="wide-container">
|
||||
@@ -586,24 +583,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
#ifndef ANDROID
|
||||
<!-- - - - - - - - - - - - - - - - - - - - - -->
|
||||
|
||||
<h2 class="major-section" id="experimental-features" data-l10n-id="experimental-features-title"/>
|
||||
|
||||
<table class="prefs-table">
|
||||
<thead class="no-copy">
|
||||
<th class="name" data-l10n-id="experimental-features-name"/>
|
||||
|
||||
<th class="value" data-l10n-id="experimental-features-value"/>
|
||||
</thead>
|
||||
|
||||
<tbody id="experimental-features-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#endif
|
||||
<!-- - - - - - - - - - - - - - - - - - - - - -->
|
||||
|
||||
<h2 class="major-section" id="remote-settings" data-l10n-id="support-remote-settings-title"/>
|
||||
|
||||
@@ -326,12 +326,6 @@ EventLoopNestingLevel:
|
||||
ping: true
|
||||
skip_if: "0"
|
||||
|
||||
ExperimentalFeatures:
|
||||
description: >
|
||||
Comma-separated list of enabled experimental features from about:preferences#experimental.
|
||||
type: string
|
||||
ping: true
|
||||
|
||||
FontName:
|
||||
description: >
|
||||
Set before attempting to load a font to help diagnose crashes during loading.
|
||||
|
||||
@@ -111,9 +111,8 @@ pub fn set_crash_ping_metrics(
|
||||
user32_loaded_before: bool = "User32BeforeBlocklist"
|
||||
}
|
||||
environment {
|
||||
experimental_features: (string_list ',') = "ExperimentalFeatures"
|
||||
nimbus_enrollments: (string_list ',') = "NimbusEnrollments"
|
||||
headless_mode: bool = "HeadlessMode"
|
||||
nimbus_enrollments: (string_list ',') = "NimbusEnrollments"
|
||||
uptime: seconds = "UptimeTS"
|
||||
}
|
||||
memory {
|
||||
|
||||
@@ -12,7 +12,6 @@ This is the nascent documentation of the Toolkit code that is shared across Fire
|
||||
components/backgroundtasks/index
|
||||
components/crashes/crash-manager/index
|
||||
crashreporter/crashreporter/index
|
||||
components/featuregates/featuregates/index
|
||||
internal-urls
|
||||
search/index
|
||||
components/normandy/normandy/index
|
||||
|
||||
@@ -97,9 +97,6 @@ show-dir-label =
|
||||
environment-variables-title = Environment Variables
|
||||
environment-variables-name = Name
|
||||
environment-variables-value = Value
|
||||
experimental-features-title = Experimental Features
|
||||
experimental-features-name = Name
|
||||
experimental-features-value = Value
|
||||
modified-key-prefs-title = Important Modified Preferences
|
||||
modified-prefs-name = Name
|
||||
modified-prefs-value = Value
|
||||
|
||||
@@ -450,27 +450,6 @@ var dataProviders = {
|
||||
done(data);
|
||||
},
|
||||
|
||||
async experimentalFeatures(done) {
|
||||
if (AppConstants.MOZ_BUILD_APP != "browser") {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
let { FeatureGate } = ChromeUtils.importESModule(
|
||||
"resource://featuregates/FeatureGate.sys.mjs"
|
||||
);
|
||||
|
||||
let gates = await FeatureGate.all();
|
||||
done(
|
||||
gates.map(gate => {
|
||||
return [
|
||||
gate.title,
|
||||
gate.preference,
|
||||
Services.prefs.getBoolPref(gate.preference),
|
||||
];
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
async legacyUserStylesheets(done) {
|
||||
if (AppConstants.platform == "android") {
|
||||
done({ active: false, types: [] });
|
||||
|
||||
@@ -13,9 +13,6 @@ const { sinon } = ChromeUtils.importESModule(
|
||||
"resource://testing-common/Sinon.sys.mjs"
|
||||
);
|
||||
|
||||
const { FeatureGate } = ChromeUtils.importESModule(
|
||||
"resource://featuregates/FeatureGate.sys.mjs"
|
||||
);
|
||||
const { PreferenceExperiments } = ChromeUtils.importESModule(
|
||||
"resource://normandy/lib/PreferenceExperiments.sys.mjs"
|
||||
);
|
||||
@@ -41,31 +38,6 @@ add_task(async function snapshotSchema() {
|
||||
}
|
||||
});
|
||||
|
||||
add_task(async function experimentalFeatures() {
|
||||
let featureGates = await FeatureGate.all();
|
||||
ok(featureGates.length, "Should be at least one FeatureGate");
|
||||
|
||||
let snapshot = await Troubleshoot.snapshot();
|
||||
for (let i = 0; i < snapshot.experimentalFeatures.length; i++) {
|
||||
let experimentalFeature = snapshot.experimentalFeatures[i];
|
||||
is(
|
||||
experimentalFeature[0],
|
||||
featureGates[i].title,
|
||||
"The first item in the array should be the title's l10n-id of the FeatureGate"
|
||||
);
|
||||
is(
|
||||
experimentalFeature[1],
|
||||
featureGates[i].preference,
|
||||
"The second item in the array should be the preference name for the FeatureGate"
|
||||
);
|
||||
is(
|
||||
experimentalFeature[2],
|
||||
Services.prefs.getBoolPref(featureGates[i].preference),
|
||||
"The third item in the array should be the preference value of the FeatureGate"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(async function modifiedPreferences() {
|
||||
let prefs = [
|
||||
"javascript.troubleshoot",
|
||||
@@ -481,10 +453,6 @@ const SNAPSHOT_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
experimentalFeatures: {
|
||||
required: true,
|
||||
type: "array",
|
||||
},
|
||||
environmentVariables: {
|
||||
required: true,
|
||||
type: "object",
|
||||
|
||||
@@ -14,7 +14,6 @@ PYTHON_UNITTEST_MANIFESTS += [
|
||||
"/testing/mochitest/tests/python/python.toml",
|
||||
"/testing/raptor/test/python.toml",
|
||||
"/testing/talos/talos/unittests/python.toml",
|
||||
"/toolkit/components/featuregates/test/python/python.toml",
|
||||
]
|
||||
|
||||
DIRS += [
|
||||
|
||||
@@ -11,7 +11,6 @@ test-manifest-toml:
|
||||
- 'testing/mozbase/manifestparser/tests/'
|
||||
- 'testing/raptor/raptor/tests/'
|
||||
- 'third_party/rust/'
|
||||
- 'toolkit/components/featuregates/test/python/data/'
|
||||
- '**/.*ruff.toml'
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cross.toml'
|
||||
|
||||
Reference in New Issue
Block a user