summaryrefslogtreecommitdiffstats
path: root/toolkit/components/featuregates/test
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/featuregates/test')
-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
9 files changed, 929 insertions, 0 deletions
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]