diff options
Diffstat (limited to 'toolkit/components/featuregates/test/unit')
4 files changed, 601 insertions, 0 deletions
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] |