forked from mirrors/gecko-dev
582 lines
15 KiB
JavaScript
582 lines
15 KiB
JavaScript
/* Any copyright is dedicated to the Public Domain.
|
|
http://creativecommons.org/publicdomain/zero/1.0/ */
|
|
|
|
"use strict";
|
|
|
|
const { RemoteSettings } = ChromeUtils.importESModule(
|
|
"resource://services-settings/remote-settings.sys.mjs"
|
|
);
|
|
const {
|
|
_ExperimentFeature: ExperimentFeature,
|
|
|
|
ExperimentAPI,
|
|
} = ChromeUtils.importESModule("resource://nimbus/ExperimentAPI.sys.mjs");
|
|
const { ExperimentTestUtils } = ChromeUtils.importESModule(
|
|
"resource://testing-common/NimbusTestUtils.sys.mjs"
|
|
);
|
|
const { ExperimentManager } = ChromeUtils.importESModule(
|
|
"resource://nimbus/lib/ExperimentManager.sys.mjs"
|
|
);
|
|
const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
|
|
"resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
|
|
);
|
|
|
|
const FOO_FAKE_FEATURE_MANIFEST = {
|
|
isEarlyStartup: true,
|
|
variables: {
|
|
remoteValue: {
|
|
type: "int",
|
|
},
|
|
enabled: {
|
|
type: "boolean",
|
|
},
|
|
},
|
|
};
|
|
|
|
const BAR_FAKE_FEATURE_MANIFEST = {
|
|
isEarlyStartup: true,
|
|
variables: {
|
|
remoteValue: {
|
|
type: "int",
|
|
},
|
|
enabled: {
|
|
type: "boolean",
|
|
},
|
|
},
|
|
};
|
|
|
|
const ENSURE_ENROLLMENT = {
|
|
targeting: "true",
|
|
bucketConfig: {
|
|
namespace: "nimbus-test-utils",
|
|
randomizationUnit: "normandy_id",
|
|
start: 0,
|
|
count: 1000,
|
|
total: 1000,
|
|
},
|
|
};
|
|
|
|
const REMOTE_CONFIGURATION_FOO = ExperimentFakes.recipe("foo-rollout", {
|
|
isRollout: true,
|
|
branches: [
|
|
{
|
|
slug: "foo-rollout-branch",
|
|
ratio: 1,
|
|
features: [
|
|
{
|
|
featureId: "foo",
|
|
isEarlyStartup: true,
|
|
value: { remoteValue: 42, enabled: true },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
...ENSURE_ENROLLMENT,
|
|
});
|
|
const REMOTE_CONFIGURATION_BAR = ExperimentFakes.recipe("bar-rollout", {
|
|
isRollout: true,
|
|
branches: [
|
|
{
|
|
slug: "bar-rollout-branch",
|
|
ratio: 1,
|
|
features: [
|
|
{
|
|
featureId: "bar",
|
|
isEarlyStartup: true,
|
|
value: { remoteValue: 3, enabled: true },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
...ENSURE_ENROLLMENT,
|
|
});
|
|
|
|
const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore.";
|
|
|
|
add_setup(function () {
|
|
const client = RemoteSettings("nimbus-desktop-experiments");
|
|
sinon.stub(client, "get").resolves([]);
|
|
|
|
registerCleanupFunction(() => client.get.restore());
|
|
});
|
|
|
|
async function setup(configuration) {
|
|
const client = RemoteSettings("nimbus-desktop-experiments");
|
|
client.get.resolves(
|
|
configuration ?? [REMOTE_CONFIGURATION_FOO, REMOTE_CONFIGURATION_BAR]
|
|
);
|
|
|
|
// Simulate a state where no experiment exists.
|
|
const cleanup = () => client.get.resolves([]);
|
|
return { client, cleanup };
|
|
}
|
|
|
|
add_task(async function test_remote_fetch_and_ready() {
|
|
const fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
|
|
const barInstance = new ExperimentFeature("bar", BAR_FAKE_FEATURE_MANIFEST);
|
|
|
|
const cleanupTestFeatures = ExperimentTestUtils.addTestFeatures(
|
|
fooInstance,
|
|
barInstance
|
|
);
|
|
|
|
const sandbox = sinon.createSandbox();
|
|
const setExperimentActiveStub = sandbox.stub(
|
|
TelemetryEnvironment,
|
|
"setExperimentActive"
|
|
);
|
|
const setExperimentInactiveStub = sandbox.stub(
|
|
TelemetryEnvironment,
|
|
"setExperimentInactive"
|
|
);
|
|
|
|
Assert.equal(
|
|
fooInstance.getVariable("remoteValue"),
|
|
undefined,
|
|
"This prop does not exist before we sync"
|
|
);
|
|
|
|
// Create to promises that get resolved when the features update
|
|
// with the remote setting rollouts
|
|
let fooUpdate = new Promise(resolve => fooInstance.onUpdate(resolve));
|
|
let barUpdate = new Promise(resolve => barInstance.onUpdate(resolve));
|
|
|
|
await ExperimentAPI.ready();
|
|
|
|
let { cleanup } = await setup();
|
|
|
|
// Fake being initialized so we can update recipes
|
|
// we don't need to start any timers
|
|
RemoteSettingsExperimentLoader._initialized = true;
|
|
await RemoteSettingsExperimentLoader.updateRecipes(
|
|
"browser_rsel_remote_defaults"
|
|
);
|
|
|
|
// We need to await here because remote configurations are processed
|
|
// async to evaluate targeting
|
|
await Promise.all([fooUpdate, barUpdate]);
|
|
|
|
Assert.equal(
|
|
fooInstance.getVariable("remoteValue"),
|
|
REMOTE_CONFIGURATION_FOO.branches[0].features[0].value.remoteValue,
|
|
"`foo` feature is set by remote defaults"
|
|
);
|
|
Assert.equal(
|
|
barInstance.getVariable("remoteValue"),
|
|
REMOTE_CONFIGURATION_BAR.branches[0].features[0].value.remoteValue,
|
|
"`bar` feature is set by remote defaults"
|
|
);
|
|
|
|
Assert.ok(
|
|
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`),
|
|
"Pref cache is set"
|
|
);
|
|
|
|
// Check if we sent active experiment data for defaults
|
|
Assert.equal(
|
|
setExperimentActiveStub.callCount,
|
|
2,
|
|
"setExperimentActive called once per feature"
|
|
);
|
|
|
|
Assert.ok(
|
|
setExperimentActiveStub.calledWith(
|
|
REMOTE_CONFIGURATION_FOO.slug,
|
|
REMOTE_CONFIGURATION_FOO.branches[0].slug,
|
|
{
|
|
type: "nimbus-rollout",
|
|
}
|
|
),
|
|
"should call setExperimentActive with `foo` feature"
|
|
);
|
|
Assert.ok(
|
|
setExperimentActiveStub.calledWith(
|
|
REMOTE_CONFIGURATION_BAR.slug,
|
|
REMOTE_CONFIGURATION_BAR.branches[0].slug,
|
|
{
|
|
type: "nimbus-rollout",
|
|
}
|
|
),
|
|
"should call setExperimentActive with `bar` feature"
|
|
);
|
|
|
|
// Test Glean experiment API interaction
|
|
Assert.equal(
|
|
Services.fog.testGetExperimentData(REMOTE_CONFIGURATION_FOO.slug).branch,
|
|
REMOTE_CONFIGURATION_FOO.branches[0].slug,
|
|
"Glean.setExperimentActive called with `foo` feature"
|
|
);
|
|
Assert.equal(
|
|
Services.fog.testGetExperimentData(REMOTE_CONFIGURATION_BAR.slug).branch,
|
|
REMOTE_CONFIGURATION_BAR.branches[0].slug,
|
|
"Glean.setExperimentActive called with `bar` feature"
|
|
);
|
|
|
|
Assert.equal(fooInstance.getVariable("remoteValue"), 42, "Has rollout value");
|
|
Assert.equal(barInstance.getVariable("remoteValue"), 3, "Has rollout value");
|
|
|
|
// Clear RS db and load again. No configurations so should clear the cache.
|
|
await cleanup();
|
|
await RemoteSettingsExperimentLoader.updateRecipes(
|
|
"browser_rsel_remote_defaults"
|
|
);
|
|
|
|
Assert.ok(
|
|
!fooInstance.getVariable("remoteValue"),
|
|
"foo-rollout should be removed"
|
|
);
|
|
Assert.ok(
|
|
!barInstance.getVariable("remoteValue"),
|
|
"bar-rollout should be removed"
|
|
);
|
|
|
|
// Check if we sent active experiment data for defaults
|
|
Assert.equal(
|
|
setExperimentInactiveStub.callCount,
|
|
2,
|
|
"setExperimentInactive called once per feature"
|
|
);
|
|
|
|
Assert.ok(
|
|
setExperimentInactiveStub.calledWith(REMOTE_CONFIGURATION_FOO.slug),
|
|
"should call setExperimentInactive with `foo` feature"
|
|
);
|
|
Assert.ok(
|
|
setExperimentInactiveStub.calledWith(REMOTE_CONFIGURATION_BAR.slug),
|
|
"should call setExperimentInactive with `bar` feature"
|
|
);
|
|
|
|
Assert.ok(
|
|
!Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
|
|
"Should clear the pref"
|
|
);
|
|
Assert.ok(!barInstance.getVariable("remoteValue"), "Should be missing");
|
|
|
|
ExperimentAPI._store._deleteForTests("foo");
|
|
ExperimentAPI._store._deleteForTests("bar");
|
|
ExperimentAPI._store._deleteForTests(REMOTE_CONFIGURATION_FOO.slug);
|
|
ExperimentAPI._store._deleteForTests(REMOTE_CONFIGURATION_BAR.slug);
|
|
sandbox.restore();
|
|
|
|
cleanupTestFeatures();
|
|
await cleanup();
|
|
});
|
|
|
|
add_task(async function test_remote_fetch_on_updateRecipes() {
|
|
let sandbox = sinon.createSandbox();
|
|
let updateRecipesStub = sandbox.stub(
|
|
RemoteSettingsExperimentLoader,
|
|
"updateRecipes"
|
|
);
|
|
// Work around the pref change callback that would trigger `setTimer`
|
|
sandbox.replaceGetter(
|
|
RemoteSettingsExperimentLoader,
|
|
"intervalInSeconds",
|
|
() => 1
|
|
);
|
|
|
|
// This will un-register the timer
|
|
RemoteSettingsExperimentLoader._initialized = true;
|
|
RemoteSettingsExperimentLoader.uninit();
|
|
Services.prefs.clearUserPref(
|
|
"app.update.lastUpdateTime.rs-experiment-loader-timer"
|
|
);
|
|
|
|
RemoteSettingsExperimentLoader.setTimer();
|
|
|
|
await BrowserTestUtils.waitForCondition(
|
|
() => updateRecipesStub.called,
|
|
"Wait for timer to call"
|
|
);
|
|
|
|
Assert.ok(updateRecipesStub.calledOnce, "Timer calls function");
|
|
Assert.equal(updateRecipesStub.firstCall.args[0], "timer", "Called by timer");
|
|
sandbox.restore();
|
|
// This will un-register the timer
|
|
RemoteSettingsExperimentLoader._initialized = true;
|
|
RemoteSettingsExperimentLoader.uninit();
|
|
Services.prefs.clearUserPref(
|
|
"app.update.lastUpdateTime.rs-experiment-loader-timer"
|
|
);
|
|
});
|
|
|
|
add_task(async function test_finalizeRemoteConfigs_cleanup() {
|
|
const featureFoo = new ExperimentFeature("foo", {
|
|
description: "mochitests",
|
|
variables: {
|
|
foo: { type: "boolean" },
|
|
},
|
|
});
|
|
const featureBar = new ExperimentFeature("bar", {
|
|
description: "mochitests",
|
|
variables: {
|
|
bar: { type: "boolean" },
|
|
},
|
|
});
|
|
|
|
const cleanupTestFeatures = ExperimentTestUtils.addTestFeatures(
|
|
featureFoo,
|
|
featureBar
|
|
);
|
|
|
|
let fooCleanup = await ExperimentFakes.enrollWithRollout(
|
|
{
|
|
featureId: "foo",
|
|
isEarlyStartup: true,
|
|
value: { foo: true },
|
|
},
|
|
{
|
|
source: "rs-loader",
|
|
}
|
|
);
|
|
await ExperimentFakes.enrollWithRollout(
|
|
{
|
|
featureId: "bar",
|
|
isEarlyStartup: true,
|
|
value: { bar: true },
|
|
},
|
|
{
|
|
source: "rs-loader",
|
|
}
|
|
);
|
|
let stubFoo = sinon.stub();
|
|
let stubBar = sinon.stub();
|
|
featureFoo.onUpdate(stubFoo);
|
|
featureBar.onUpdate(stubBar);
|
|
let cleanupPromise = new Promise(resolve => featureBar.onUpdate(resolve));
|
|
|
|
// stubFoo and stubBar will be called because the store is ready. We are not interested in these calls.
|
|
// Reset call history and check calls stats after cleanup.
|
|
Assert.ok(
|
|
stubFoo.called,
|
|
"feature foo update triggered becuase store is ready"
|
|
);
|
|
Assert.ok(
|
|
stubBar.called,
|
|
"feature bar update triggered because store is ready"
|
|
);
|
|
stubFoo.resetHistory();
|
|
stubBar.resetHistory();
|
|
|
|
Services.prefs.setStringPref(
|
|
`${SYNC_DEFAULTS_PREF_BRANCH}foo`,
|
|
JSON.stringify({ foo: true, branch: { feature: { featureId: "foo" } } })
|
|
);
|
|
Services.prefs.setStringPref(
|
|
`${SYNC_DEFAULTS_PREF_BRANCH}bar`,
|
|
JSON.stringify({ bar: true, branch: { feature: { featureId: "bar" } } })
|
|
);
|
|
|
|
const remoteConfiguration = {
|
|
...REMOTE_CONFIGURATION_FOO,
|
|
branches: [
|
|
{
|
|
...REMOTE_CONFIGURATION_FOO.branches[0],
|
|
features: [
|
|
{
|
|
...REMOTE_CONFIGURATION_FOO.branches[0].features[0],
|
|
value: {
|
|
foo: true,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const { cleanup } = await setup([remoteConfiguration]);
|
|
RemoteSettingsExperimentLoader._initialized = true;
|
|
await RemoteSettingsExperimentLoader.updateRecipes();
|
|
await cleanupPromise;
|
|
|
|
Assert.ok(
|
|
stubFoo.notCalled,
|
|
"Not called, not enrolling in rollout feature already exists"
|
|
);
|
|
Assert.ok(stubBar.called, "Called because no recipe is seen, cleanup");
|
|
Assert.ok(
|
|
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}foo`),
|
|
"Pref is not cleared"
|
|
);
|
|
Assert.ok(
|
|
!Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
|
|
"Pref was cleared"
|
|
);
|
|
|
|
await fooCleanup();
|
|
// This will also remove the inactive recipe from the store
|
|
// the previous update (from recipe not seen code path)
|
|
// only sets the recipe as inactive
|
|
ExperimentAPI._store._deleteForTests("bar-rollout");
|
|
ExperimentAPI._store._deleteForTests("foo-rollout");
|
|
|
|
cleanupTestFeatures();
|
|
cleanup();
|
|
});
|
|
|
|
// If the remote config data returned from the store is not modified
|
|
// this test should not throw
|
|
add_task(async function remote_defaults_no_mutation() {
|
|
let sandbox = sinon.createSandbox();
|
|
sandbox.stub(ExperimentAPI._store, "getRolloutForFeature").returns(
|
|
Cu.cloneInto(
|
|
{
|
|
featureIds: ["foo"],
|
|
branch: {
|
|
features: [{ featureId: "foo", value: { remoteStub: true } }],
|
|
},
|
|
},
|
|
{},
|
|
{ deepFreeze: true }
|
|
)
|
|
);
|
|
|
|
let fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
|
|
let config = fooInstance.getAllVariables();
|
|
|
|
Assert.ok(config.remoteStub, "Got back the expected value");
|
|
|
|
sandbox.restore();
|
|
});
|
|
|
|
add_task(async function remote_defaults_active_remote_defaults() {
|
|
ExperimentAPI._store._deleteForTests("foo");
|
|
ExperimentAPI._store._deleteForTests("bar");
|
|
let barFeature = new ExperimentFeature("bar", {
|
|
description: "mochitest",
|
|
variables: { enabled: { type: "boolean" } },
|
|
});
|
|
let fooFeature = new ExperimentFeature("foo", {
|
|
description: "mochitest",
|
|
variables: { enabled: { type: "boolean" } },
|
|
});
|
|
|
|
const cleanupTestFeatures = ExperimentTestUtils.addTestFeatures(
|
|
barFeature,
|
|
fooFeature
|
|
);
|
|
|
|
let rollout1 = ExperimentFakes.recipe("bar", {
|
|
branches: [
|
|
{
|
|
slug: "bar-rollout-branch",
|
|
ratio: 1,
|
|
features: [
|
|
{
|
|
featureId: "bar",
|
|
value: { enabled: true },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
isRollout: true,
|
|
...ENSURE_ENROLLMENT,
|
|
targeting: "true",
|
|
});
|
|
|
|
let rollout2 = ExperimentFakes.recipe("foo", {
|
|
branches: [
|
|
{
|
|
slug: "foo-rollout-branch",
|
|
ratio: 1,
|
|
features: [
|
|
{
|
|
featureId: "foo",
|
|
value: { enabled: true },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
isRollout: true,
|
|
...ENSURE_ENROLLMENT,
|
|
targeting: "'bar' in activeRollouts",
|
|
});
|
|
|
|
// Order is important, rollout2 won't match at first
|
|
const { cleanup } = await setup([rollout2, rollout1]);
|
|
let updatePromise = new Promise(resolve => barFeature.onUpdate(resolve));
|
|
RemoteSettingsExperimentLoader._initialized = true;
|
|
await RemoteSettingsExperimentLoader.updateRecipes("mochitest");
|
|
|
|
await updatePromise;
|
|
|
|
Assert.ok(barFeature.getVariable("enabled"), "Enabled on first sync");
|
|
Assert.ok(!fooFeature.getVariable("enabled"), "Targeting doesn't match");
|
|
|
|
let featureUpdate = new Promise(resolve => fooFeature.onUpdate(resolve));
|
|
await RemoteSettingsExperimentLoader.updateRecipes("mochitest");
|
|
await featureUpdate;
|
|
|
|
Assert.ok(fooFeature.getVariable("enabled"), "Targeting should match");
|
|
ExperimentAPI._store._deleteForTests("foo");
|
|
ExperimentAPI._store._deleteForTests("bar");
|
|
|
|
cleanup();
|
|
cleanupTestFeatures();
|
|
});
|
|
|
|
add_task(async function remote_defaults_variables_storage() {
|
|
let barFeature = new ExperimentFeature("bar", {
|
|
description: "mochitest",
|
|
variables: {
|
|
enabled: {
|
|
type: "boolean",
|
|
},
|
|
storage: {
|
|
type: "int",
|
|
},
|
|
object: {
|
|
type: "json",
|
|
},
|
|
string: {
|
|
type: "string",
|
|
},
|
|
bool: {
|
|
type: "boolean",
|
|
},
|
|
},
|
|
});
|
|
let rolloutValue = {
|
|
storage: 42,
|
|
object: { foo: "foo" },
|
|
string: "string",
|
|
bool: true,
|
|
enabled: true,
|
|
};
|
|
|
|
let doCleanup = await ExperimentFakes.enrollWithRollout({
|
|
featureId: "bar",
|
|
isEarlyStartup: true,
|
|
value: rolloutValue,
|
|
});
|
|
|
|
Assert.ok(
|
|
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
|
|
"Experiment stored in prefs"
|
|
);
|
|
Assert.ok(
|
|
Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, 0),
|
|
"Stores variable in separate pref"
|
|
);
|
|
Assert.equal(
|
|
Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, 0),
|
|
42,
|
|
"Stores variable in correct type"
|
|
);
|
|
Assert.deepEqual(
|
|
barFeature.getAllVariables(),
|
|
rolloutValue,
|
|
"Test types are returned correctly"
|
|
);
|
|
|
|
await doCleanup();
|
|
|
|
Assert.equal(
|
|
Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, -1),
|
|
-1,
|
|
"Variable pref is cleared"
|
|
);
|
|
Assert.ok(!barFeature.getVariable("string"), "Variable is no longer defined");
|
|
ExperimentAPI._store._deleteForTests("bar");
|
|
ExperimentAPI._store._deleteForTests("bar-rollout");
|
|
});
|