diff options
Diffstat (limited to 'toolkit/components/featuregates/test')
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] |