1
0
Fork 0
firefox/toolkit/components/nimbus/test/unit/test_Migrations.js
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

1530 lines
40 KiB
JavaScript

/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const {
LABS_MIGRATION_FEATURE_MAP,
LEGACY_NIMBUS_MIGRATION_PREF,
MigrationError,
NIMBUS_MIGRATION_PREFS,
NimbusMigrations,
} = ChromeUtils.importESModule("resource://nimbus/lib/Migrations.sys.mjs");
const { NimbusTelemetry } = ChromeUtils.importESModule(
"resource://nimbus/lib/Telemetry.sys.mjs"
);
const { ProfilesDatastoreService } = ChromeUtils.importESModule(
"moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs"
);
/** @typedef {import("../../lib/Migrations.sys.mjs").Migration} Migration */
/** @typedef {import("../../lib/Migrations.sys.mjs").Phase} Phase */
function mockLabsRecipes(targeting = "true") {
return Object.entries(LABS_MIGRATION_FEATURE_MAP).map(([featureId, slug]) =>
NimbusTestUtils.factories.recipe.withFeatureConfig(
slug,
{ featureId, value: { enabled: true } },
{
isRollout: true,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: `${featureId}-placeholder-title`,
firefoxLabsDescription: `${featureId}-placeholder-desc`,
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "placeholder",
targeting,
}
)
);
}
function getEnabledPrefForFeature(featureId) {
return NimbusFeatures[featureId].manifest.variables.enabled.setPref.pref;
}
add_setup(async function setup() {
Services.fog.initializeFOG();
});
/**
* Setup a test environment.
*
* @param {object} options
* @param {number?} options.legacyMigrationState
* The value of the legacy migration pref.
* @param {Record<Phase, number>?} options.migrationState
* The value that should be set for the Nimbus migration prefs. If
* not provided, the pref will be unset.
* @param {Record<Phase, Migration[]>} options.migrations
* An array of migrations that will replace the regular set of migrations
* for the duration of the test.
* @param {object[]} options.recipes
* An array of experiment recipes that will be returned by the
* RemoteSettingsExperimentLoader for the duration of the test.
* @param {object} options.args
* Options to pass to to NimbusTestutils.setupNimbusTest.
*/
async function setupTest({
legacyMigrationState,
migrationState,
migrations,
init = true,
...args
} = {}) {
Assert.ok(
!Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF),
`legacy migration pref should be unset`
);
for (const [phase, pref] of Object.keys(NIMBUS_MIGRATION_PREFS)) {
Assert.ok(
!Services.prefs.prefHasUserValue(pref),
`${phase} migration pref should be unset`
);
}
const { initExperimentAPI, ...ctx } = await NimbusTestUtils.setupTest({
init: false,
clearTelemetry: true,
...args,
});
const { sandbox } = ctx;
if (migrationState) {
for (const [phase, value] of Object.entries(migrationState)) {
Services.prefs.setIntPref(NIMBUS_MIGRATION_PREFS[phase], value);
}
}
if (typeof legacyMigrationState !== "undefined") {
Services.prefs.setIntPref(
LEGACY_NIMBUS_MIGRATION_PREF,
legacyMigrationState
);
}
if (migrations) {
// If the test only specifies some of the phases ensure that there are
// placeholders so that NimbusMigrations doesn't get mad.
const migrationsStub = Object.assign(
Object.fromEntries(
Object.values(NimbusMigrations.Phase).map(phase => [phase, []])
),
migrations
);
sandbox.stub(NimbusMigrations, "MIGRATIONS").get(() => migrationsStub);
}
if (init) {
await initExperimentAPI();
} else {
ctx.initExperimentAPI = initExperimentAPI;
}
return ctx;
}
function makeMigrations(phase, count) {
const migrations = [];
for (let i = 0; i < count; i++) {
migrations.push(
NimbusMigrations.migration(`test-migration-${phase}-${i}`, sinon.stub())
);
}
return migrations;
}
add_task(async function test_migration_unset() {
info("Testing NimbusMigrations with no migration pref set");
const startupMigrations = makeMigrations(
NimbusMigrations.Phase.INIT_STARTED,
2
);
const storeMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_STORE_INITIALIZED,
2
);
const updateMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE,
1
);
const { cleanup } = await setupTest({
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: startupMigrations,
[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations,
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations,
},
});
Assert.ok(
startupMigrations[0].fn.calledOnce,
`${startupMigrations[0].name} should be called once`
);
Assert.ok(
startupMigrations[1].fn.calledOnce,
`${startupMigrations[1].name} should be called once`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED]
),
1,
"Migration pref should be updated"
);
Assert.ok(
storeMigrations[0].fn.calledOnce,
`${storeMigrations[0].name} should be called once`
);
Assert.ok(
storeMigrations[1].fn.calledOnce,
`${storeMigrations[1].name} should be called once`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]
),
1,
"Migration pref should be updated"
);
Assert.ok(
updateMigrations[0].fn.calledOnce,
`${updateMigrations[0].name} should be called once`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
0,
"Migration pref should be updated"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: startupMigrations[0].name,
},
{
success: "true",
migration_id: startupMigrations[1].name,
},
{
success: "true",
migration_id: storeMigrations[0].name,
},
{
success: "true",
migration_id: storeMigrations[1].name,
},
{
success: "true",
migration_id: updateMigrations[0].name,
},
]
);
await cleanup();
});
add_task(async function test_migration_partially_done() {
info("Testing NimbusMigrations with some migrations completed");
const startupMigrations = makeMigrations(
NimbusMigrations.Phase.INIT_STARTED,
2
);
const storeMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_STORE_INITIALIZED,
2
);
const updateMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE,
2
);
const { cleanup } = await setupTest({
migrationState: {
[NimbusMigrations.Phase.INIT_STARTED]: 0,
[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: 0,
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: 0,
},
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: startupMigrations,
[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations,
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations,
},
});
Assert.ok(
startupMigrations[0].fn.notCalled,
`${startupMigrations[0].name} should not be called`
);
Assert.ok(
startupMigrations[1].fn.calledOnce,
`${startupMigrations[1].name} should be called once`
);
Assert.ok(
storeMigrations[0].fn.notCalled,
`${updateMigrations[0].name} should not be called`
);
Assert.ok(
storeMigrations[1].fn.calledOnce,
`${updateMigrations[1].name} should be called once`
);
Assert.ok(
updateMigrations[0].fn.notCalled,
`${updateMigrations[0].name} should not be called`
);
Assert.ok(
updateMigrations[1].fn.calledOnce,
`${updateMigrations[1].name} should be called once`
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: startupMigrations[1].name,
},
{
success: "true",
migration_id: storeMigrations[1].name,
},
{
success: "true",
migration_id: updateMigrations[1].name,
},
]
);
await cleanup();
});
add_task(async function test_migration_throws() {
info(
"Testing NimbusMigrations with a migration that throws an unknown error"
);
const startupMigrations = makeMigrations(
NimbusMigrations.Phase.INIT_STARTED,
3
);
startupMigrations[1].fn.throws(
new Error(`${startupMigrations[1].name} failed`)
);
const storeMigrations = makeMigrations(
NimbusMigrations.Phase.INIT_STARTED,
3
);
storeMigrations[1].fn.throws(new Error(`${storeMigrations[1].name} failed`));
const updateMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE,
3
);
updateMigrations[1].fn.throws(
new Error(`${updateMigrations[1].name} failed`)
);
const { cleanup } = await setupTest({
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: startupMigrations,
[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations,
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations,
},
});
Assert.ok(
startupMigrations[0].fn.calledOnce,
`${startupMigrations[0].name} should be called once`
);
Assert.ok(
startupMigrations[1].fn.calledOnce,
`${startupMigrations[1].name} should be called once`
);
Assert.ok(
startupMigrations[2].fn.notCalled,
`${startupMigrations[2].name} should not be called`
);
Assert.ok(
storeMigrations[0].fn.calledOnce,
`${storeMigrations[0].name} should be called once`
);
Assert.ok(
storeMigrations[1].fn.calledOnce,
`${storeMigrations[1].name} should be called once`
);
Assert.ok(
storeMigrations[2].fn.notCalled,
`${storeMigrations[2].name} should not be called`
);
Assert.ok(
updateMigrations[0].fn.calledOnce,
`${updateMigrations[0].name} should be called once`
);
Assert.ok(
updateMigrations[1].fn.calledOnce,
`${updateMigrations[1].name} should be called once`
);
Assert.ok(
updateMigrations[2].fn.notCalled,
`${updateMigrations[2].name} should not be called`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED]
),
0,
"Migration pref should only be set to 0"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]
),
0,
"Migration pref should only be set to 0"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
0,
"Migration pref should only be set to 0"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: startupMigrations[0].name,
},
{
success: "false",
migration_id: startupMigrations[1].name,
error_reason: MigrationError.Reason.UNKNOWN,
},
{
success: "true",
migration_id: storeMigrations[0].name,
},
{
success: "false",
migration_id: storeMigrations[1].name,
error_reason: MigrationError.Reason.UNKNOWN,
},
{
success: "true",
migration_id: updateMigrations[0].name,
},
{
success: "false",
migration_id: updateMigrations[1].name,
error_reason: MigrationError.Reason.UNKNOWN,
},
]
);
await cleanup();
});
add_task(async function test_migration_throws_MigrationError() {
info(
"Testing NimbusMigrations with a migration that throws a MigrationError"
);
const startupMigrations = makeMigrations(
NimbusMigrations.Phase.INIT_STARTED,
3
);
startupMigrations[1].fn.throws(new MigrationError("bogus"));
const storeMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_STORE_INITIALIZED,
3
);
storeMigrations[1].fn.throws(new MigrationError("bogus"));
const updateMigrations = makeMigrations(
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE,
3
);
updateMigrations[1].fn.throws(new MigrationError("bogus"));
const { cleanup } = await setupTest({
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: startupMigrations,
[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations,
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations,
},
});
Assert.ok(
startupMigrations[0].fn.calledOnce,
`${startupMigrations[0].name} should be called once`
);
Assert.ok(
startupMigrations[1].fn.calledOnce,
`${startupMigrations[1].name} should be called once`
);
Assert.ok(
startupMigrations[2].fn.notCalled,
`${startupMigrations[2].name} should not be called`
);
Assert.ok(
storeMigrations[0].fn.calledOnce,
`${storeMigrations[0].name} should be called once`
);
Assert.ok(
storeMigrations[1].fn.calledOnce,
`${storeMigrations[1].name} should be called once`
);
Assert.ok(
storeMigrations[2].fn.notCalled,
`${storeMigrations[2].name} should not be called`
);
Assert.ok(
updateMigrations[0].fn.calledOnce,
`${updateMigrations[0].name} should be called once`
);
Assert.ok(
updateMigrations[1].fn.calledOnce,
`${updateMigrations[1].name} should be called once`
);
Assert.ok(
updateMigrations[2].fn.notCalled,
`${updateMigrations[2].name} should not be called`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED]
),
0,
"Migration pref should only be set to 0"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]
),
0,
"Migration pref should only be set to 0"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
0,
"Migration pref should only be set to 0"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: startupMigrations[0].name,
},
{
success: "false",
migration_id: startupMigrations[1].name,
error_reason: "bogus",
},
{
success: "true",
migration_id: storeMigrations[0].name,
},
{
success: "false",
migration_id: storeMigrations[1].name,
error_reason: "bogus",
},
{
success: "true",
migration_id: updateMigrations[0].name,
},
{
success: "false",
migration_id: updateMigrations[1].name,
error_reason: "bogus",
},
]
);
await cleanup();
});
const LEGACY_TO_MULTIPHASE_MIGRATION =
NimbusMigrations.MIGRATIONS[NimbusMigrations.Phase.INIT_STARTED][0];
add_task(async function test_migration_legacyToMultiphase_unset() {
const migrations = makeMigrations(NimbusMigrations.Phase.INIT_STARTED, 2);
const { cleanup } = await setupTest({
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: [LEGACY_TO_MULTIPHASE_MIGRATION],
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: migrations,
},
});
Assert.ok(
migrations[0].fn.calledOnce,
`${migrations[0].name} should be called`
);
Assert.ok(
migrations[1].fn.calledOnce,
`${migrations[1].name} should be called`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED]
),
0,
"before-manager-startup phase pref should be set"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
1,
"after-remote-setttings-update phase pref should be set"
);
Assert.ok(
!Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF),
"legacy phase pref is unset"
);
await cleanup();
});
add_task(async function test_migration_legacyToMultiphase_partial() {
const migrations = makeMigrations(
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE,
3
);
const { cleanup } = await setupTest({
legacyMigrationState: 1,
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: [LEGACY_TO_MULTIPHASE_MIGRATION],
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: migrations,
},
});
Assert.ok(
migrations[0].fn.notCalled,
`${migrations[0].name} should not be called`
);
Assert.ok(
migrations[1].fn.notCalled,
`${migrations[1].name} should not be called`
);
Assert.ok(
migrations[2].fn.calledOnce,
`${migrations[2].name} should be called`
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED]
),
0,
"before-manager-startup phase pref should be set"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
2,
"after-remote-setttings-update phase pref should be set"
);
Assert.ok(
!Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF),
"legacy phase pref is unset"
);
await cleanup();
});
add_task(async function test_migration_legacyToMultiphase_complete() {
const migrations = makeMigrations(
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE,
2
);
const { cleanup } = await setupTest({
legacyMigrationState: 1,
migrations: {
[NimbusMigrations.Phase.INIT_STARTED]: [LEGACY_TO_MULTIPHASE_MIGRATION],
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: migrations,
},
});
Assert.ok(migrations[0].fn.notCalled, `${migrations[0].name} not called`);
Assert.ok(migrations[1].fn.notCalled, `${migrations[1].name} not called`);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED]
),
0,
"before-manager-startup phase pref should be set"
);
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
1,
"after-remote-setttings-update phase pref should be set"
);
Assert.ok(
!Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF),
"legacy phase pref is unset"
);
await cleanup();
});
const FIREFOX_LABS_MIGRATION =
NimbusMigrations.MIGRATIONS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
][0];
add_task(async function test_migration_firefoxLabsEnrollments() {
async function doTest(features) {
info(
`Testing NimbusMigrations migrates Firefox Labs features ${JSON.stringify(features)}`
);
const prefs = features.map(getEnabledPrefForFeature);
for (const pref of prefs) {
Services.prefs.setBoolPref(pref, true);
}
const { manager, cleanup } = await setupTest({
experiments: mockLabsRecipes("true"),
migrations: {
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [
FIREFOX_LABS_MIGRATION,
],
},
});
Assert.deepEqual(
await manager
.getAllOptInRecipes()
.then(recipes => recipes.map(recipe => recipe.slug).toSorted()),
Object.values(LABS_MIGRATION_FEATURE_MAP).toSorted(),
"The labs recipes should be available"
);
for (const [feature, slug] of Object.entries(LABS_MIGRATION_FEATURE_MAP)) {
const enrollmentExpected = features.includes(feature);
const enrollment = manager.store.get(slug);
if (enrollmentExpected) {
Assert.ok(
!!enrollment,
`There should be an enrollment for slug ${slug}`
);
const pref = getEnabledPrefForFeature(feature);
Assert.equal(
Services.prefs.getBoolPref(pref),
true,
`Pref ${pref} should be set after enrollment`
);
await manager.unenroll(slug);
Assert.equal(
Services.prefs.getBoolPref(pref),
false,
`Pref ${pref} should be unset after unenrollment`
);
Assert.ok(
!Services.prefs.prefHasUserValue(pref),
`Pref ${pref} should not be set on the user branch`
);
} else {
Assert.ok(
!enrollment,
`There should not be an enrollment for slug ${slug}`
);
}
}
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: "firefox-labs-enrollments",
},
]
);
await cleanup();
}
await doTest([]);
for (const feature of Object.keys(LABS_MIGRATION_FEATURE_MAP)) {
await doTest([feature]);
}
await doTest(Object.keys(LABS_MIGRATION_FEATURE_MAP));
});
add_task(async function test_migration_firefoxLabsEnrollments_falseTargeting() {
// Some of the features will be limited to specific channels.
// We don't need to test that targeting evaluation itself works, so we'll just
// test with hardcoded targeting.
info(
`Testing NimbusMigration does not migrate Firefox Labs features when targeting is false`
);
const prefs = Object.keys(LABS_MIGRATION_FEATURE_MAP).map(
getEnabledPrefForFeature
);
for (const pref of prefs) {
Services.prefs.setBoolPref(pref, true);
}
const { manager, cleanup } = await setupTest({
experiments: mockLabsRecipes("false"),
migrations: {
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [
FIREFOX_LABS_MIGRATION,
],
},
});
Assert.deepEqual(
await manager.getAllOptInRecipes(),
[],
"There should be no opt-in recipes"
);
for (const pref of prefs) {
Assert.ok(
Services.prefs.getBoolPref(pref),
`Pref ${pref} should be unchanged`
);
Services.prefs.clearUserPref(pref);
}
for (const slug of Object.values(LABS_MIGRATION_FEATURE_MAP)) {
Assert.ok(
typeof manager.store.get(slug) === "undefined",
`There should be no store entry for ${slug}`
);
}
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: "firefox-labs-enrollments",
},
]
);
await cleanup();
});
add_task(async function test_migration_firefoxLabsEnrollments_idempotent() {
info("Testing the firefox-labs-enrollments migration is idempotent");
const prefs = Object.keys(LABS_MIGRATION_FEATURE_MAP).map(
getEnabledPrefForFeature
);
for (const pref of prefs) {
Services.prefs.setBoolPref(pref, true);
}
const recipes = mockLabsRecipes("true");
// Get the store into a partially migrated state (i.e., we have enrolled in at least one
// experiment but the migration pref has not updated).
{
const manager = NimbusTestUtils.stubs.manager();
await manager.store.init();
await manager.onStartup();
manager.enroll(recipes[0], "rs-loader", { branchSlug: "control" });
await NimbusTestUtils.saveStore(manager.store);
removePrefObservers(manager);
assertNoObservers(manager);
}
const { manager, cleanup } = await setupTest({
experiments: recipes,
migrations: {
[NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [
FIREFOX_LABS_MIGRATION,
],
},
});
Assert.equal(
Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREFS[
NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE
]
),
0,
"Migration pref updated"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(ev => ev.extra),
[
{
migration_id: "firefox-labs-enrollments",
success: "true",
},
]
);
for (const { slug } of recipes) {
await manager.unenroll(slug);
}
await cleanup();
for (const pref of prefs) {
Services.prefs.clearUserPref(pref);
}
});
const IMPORT_TO_SQL_MIGRATION = NimbusMigrations.MIGRATIONS[
NimbusMigrations.Phase.AFTER_STORE_INITIALIZED
].find(m => m.name === "import-enrollments-to-sql");
add_task(async function testMigrateEnrollmentsToSql() {
const PREFFLIPS_EXPERIMENT_VALUE = {
prefs: {
"foo.bar.baz": {
branch: "default",
value: "prefFlips-experiment-value",
},
},
};
let storePath;
const experiments = [
NimbusTestUtils.factories.recipe.withFeatureConfig(
"experiment-1",
{
branchSlug: "experiment-1",
featureId: "no-feature-firefox-desktop",
},
{
bogus: "foobar",
}
),
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
branchSlug: "experiment-2",
featureId: "no-feature-firefox-desktop",
}),
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-1",
{
featureId: "no-feature-firefox-desktop",
},
{
isRollout: true,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "title",
firefoxLabsDescription: "description",
firefoxLabsDescriptionLinks: {
foo: "https://example.com",
},
firefoxLabsGroup: "group",
requiresRestart: true,
}
),
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-2",
{
featureId: "no-feature-firefox-desktop",
},
{ isRollout: true }
),
NimbusTestUtils.factories.recipe.withFeatureConfig("setPref-experiment", {
featureId: "nimbus-qa-1",
value: {
value: "qa-1",
},
}),
NimbusTestUtils.factories.recipe.withFeatureConfig(
"setPref-rollout",
{
featureId: "nimbus-qa-2",
value: {
value: "qa-2",
},
},
{ isRollout: true }
),
];
const secureExperiments = [
NimbusTestUtils.factories.recipe.withFeatureConfig("prefFlips-experiment", {
featureId: "prefFlips",
value: PREFFLIPS_EXPERIMENT_VALUE,
}),
NimbusTestUtils.factories.recipe.withFeatureConfig(
"prefFlips-rollout",
{ featureId: "prefFlips", value: { prefs: {} } },
{ isRollout: true }
),
];
{
const store = NimbusTestUtils.stubs.store();
await store.init();
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"inactive-1",
{ featureId: "no-feature-firefox-desktop" },
{
active: false,
unenrollReason: "reason-1",
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
}
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"inactive-2",
{ branchSlug: "treatment-a", featureId: "no-feature-firefox-desktop" },
{
active: false,
unenrollReason: "reason-2",
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
}
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"expired-but-active",
{ featureId: "no-feature-firefox-desktop" },
{ source: NimbusTelemetry.EnrollmentSource.RS_LOADER }
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"experiment-1",
{
branchSlug: "experiment-1",
featureId: "no-feature-firefox-desktop",
},
{ source: NimbusTelemetry.EnrollmentSource.RS_LOADER }
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"rollout-1",
{ featureId: "no-feature-firefox-desktop" },
{
isRollout: true,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "title",
firefoxLabsDescription: "description",
firefoxLabsDescriptionLinks: {
foo: "https://example.com",
},
firefoxLabsGroup: "group",
requiresRestart: true,
}
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"prefFlips-experiment",
{
featureId: "prefFlips",
value: PREFFLIPS_EXPERIMENT_VALUE,
},
{
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
prefFlips: {
originalValues: {
"foo.bar.baz": "original-value",
},
},
}
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"setPref-experiment",
{
featureId: "nimbus-qa-1",
value: { value: "qa-1" },
},
{
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
prefs: [
{
name: "nimbus.qa.pref-1",
branch: "default",
featureId: "nimbus-qa-1",
variable: "value",
originalValue: "original-value",
},
],
}
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"devtools",
{
branchSlug: "devtools",
featureId: "no-feature-firefox-desktop",
},
{ source: "nimbus-devtools" }
)
);
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"optin",
{
branchSlug: "force-enroll",
featureId: "no-feature-firefox-desktop",
},
{
source: NimbusTelemetry.EnrollmentSource.FORCE_ENROLLMENT,
localizations: {
"en-US": {
foo: "foo",
},
},
}
)
);
storePath = await NimbusTestUtils.saveStore(store);
}
let importMigrationError = null;
// We need to run our test *directly after* the migration completes, before
// the rest of Nimbus has had a chance to initialize, so we know that any
// changes to the database were from the migration and not, e.g.,
// updateRecipes().
async function testImportMigration() {
try {
const conn = await ProfilesDatastoreService.getConnection();
const result = await conn.execute(`
SELECT
profileId,
slug,
branchSlug,
json(recipe) AS recipe,
active,
unenrollReason,
lastSeen,
json(setPrefs) AS setPrefs,
json(prefFlips) AS prefFlips,
source
FROM NimbusEnrollments
`);
const dbEnrollments = Object.fromEntries(
result.map(row => {
const fields = [
"profileId",
"slug",
"branchSlug",
"recipe",
"active",
"unenrollReason",
"lastSeen",
"setPrefs",
"prefFlips",
"source",
];
const processed = {};
for (const field of fields) {
processed[field] = row.getResultByName(field);
}
processed.recipe = JSON.parse(processed.recipe);
processed.setPrefs = JSON.parse(processed.setPrefs);
processed.prefFlips = JSON.parse(processed.prefFlips);
return [processed.slug, processed];
})
);
Assert.deepEqual(
Object.keys(dbEnrollments).sort(),
[
"inactive-1",
"inactive-2",
"expired-but-active",
"experiment-1",
"rollout-1",
"setPref-experiment",
"prefFlips-experiment",
"devtools",
"optin",
].sort(),
"Should have rows for the expected enrollments"
);
// The profileId is the same for every enrollment.
const profileId = ExperimentAPI.profileId;
function assertEnrollment(expected) {
const enrollment = dbEnrollments[expected.slug];
const { slug } = expected;
function msg(s) {
return `${slug}: ${s}`;
}
Assert.equal(enrollment.slug, slug, "slug matches");
Assert.equal(enrollment.profileId, profileId, msg("profileId"));
Assert.equal(enrollment.active, expected.active, msg("active"));
Assert.equal(
enrollment.unenrollReason,
expected.unenrollReason,
msg("unenrollReason")
);
Assert.deepEqual(
enrollment.setPrefs,
expected.setPrefs,
msg("setPrefs")
);
Assert.deepEqual(
enrollment.prefFlips,
expected.prefFlips,
msg("prefFlips")
);
Assert.equal(enrollment.source, expected.source, msg("source"));
Assert.ok(
typeof enrollment.lastSeen === "string",
msg("lastSeen serialized as string")
);
Assert.ok(
typeof enrollment.recipe === "object" && enrollment.recipe !== null,
msg("recipe is object")
);
const requiredRecipeFields = [
"slug",
"userFacingName",
"userFacingDescription",
"featureIds",
"isRollout",
"localizations",
"isFirefoxLabsOptIn",
"firefoxLabsDescription",
"firefoxLabsDescriptionLinks",
"firefoxLabsGroup",
"requiresRestart",
"branches",
];
for (const recipeField of requiredRecipeFields) {
Assert.ok(
Object.hasOwn(enrollment.recipe, recipeField),
msg(`recipe has ${recipeField} field`)
);
}
const storeEnrollment = ExperimentAPI.manager.store.get(slug);
Assert.equal(enrollment.recipe.slug, slug, msg("recipe.slug"));
Assert.equal(
enrollment.recipe.isRollout,
storeEnrollment.isRollout,
msg("recipe.isRollout")
);
Assert.ok(
enrollment.recipe.branches.find(
b => b.slug === enrollment.branchSlug
),
msg("recipe has branch matching branchSlug")
);
for (const [i, branch] of enrollment.recipe.branches.entries()) {
Assert.ok(
typeof branch.ratio === "number",
msg(`recipe.branches[${i}].ratio is a number`)
);
Assert.ok(
typeof branch.features === "object" && branch.features !== null,
msg(`recipe.branches[${i}].features is an object`)
);
for (const featureId of enrollment.recipe.featureIds) {
const idx = branch.features.findIndex(
fc => fc.featureId === featureId
);
Assert.ok(
idx !== -1,
msg(
`recipe.branches[${i}].features[${idx}].featureId = ${featureId}`
)
);
const featureConfig = branch.features[idx];
Assert.ok(
typeof featureConfig.value === "object" &&
featureConfig.value !== null,
msg(`recipe.branches[${i}].features[${idx}].value is an object`)
);
}
}
}
assertEnrollment({
slug: "inactive-1",
branchSlug: "control",
active: false,
unenrollReason: "reason-1",
setPrefs: null,
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
assertEnrollment({
slug: "inactive-2",
branchSlug: "treatment-a",
active: false,
unenrollReason: "reason-2",
setPrefs: null,
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
assertEnrollment({
slug: "expired-but-active",
branchSlug: "control",
active: true,
unenrollReason: null,
setPrefs: null,
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
assertEnrollment({
slug: "experiment-1",
branchSlug: "experiment-1",
active: true,
unenrollReason: null,
setPrefs: null,
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
Assert.equal(
dbEnrollments["experiment-1"].recipe.bogus,
"foobar",
`experiment-1: entire recipe from Remote Settings is captured`
);
assertEnrollment({
slug: "rollout-1",
branchSlug: "control",
active: true,
unenrollReason: null,
setPrefs: null,
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
Assert.ok(
dbEnrollments["rollout-1"].recipe.isFirefoxLabsOptIn,
`rollout-1: recipe.isFirefoxLabsOptIn`
);
Assert.equal(
dbEnrollments["rollout-1"].recipe.firefoxLabsTitle,
"title",
`rollout-1: recipe.firefoxLabsTitle`
);
Assert.equal(
dbEnrollments["rollout-1"].recipe.firefoxLabsDescription,
"description",
`rollout-1: recipe.firefoxLabsDescription`
);
Assert.deepEqual(
dbEnrollments["rollout-1"].recipe.firefoxLabsDescriptionLinks,
{
foo: "https://example.com",
},
`rollout-1: recipe.firefoxLabsDescriptionLinks`
);
Assert.equal(
dbEnrollments["rollout-1"].recipe.firefoxLabsGroup,
"group",
`rollout-1: recipe.firefoxLabsGroup`
);
Assert.ok(
dbEnrollments["rollout-1"].recipe.requiresRestart,
`rollout-1: recipe.requiresRestart`
);
assertEnrollment({
slug: "prefFlips-experiment",
branchSlug: "control",
active: true,
unenrollReason: null,
setPrefs: null,
prefFlips: {
originalValues: {
"foo.bar.baz": "original-value",
},
},
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
assertEnrollment({
slug: "setPref-experiment",
branchSlug: "control",
active: true,
unenrollReason: null,
setPrefs: [
{
name: "nimbus.qa.pref-1",
branch: "default",
featureId: "nimbus-qa-1",
variable: "value",
originalValue: "original-value",
},
],
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.RS_LOADER,
});
assertEnrollment({
slug: "devtools",
branchSlug: "devtools",
active: true,
unenrollReason: null,
setPrefs: null,
prefFlips: null,
source: "nimbus-devtools",
});
assertEnrollment({
slug: "optin",
branchSlug: "force-enroll",
active: true,
unenrollReason: null,
setPrefs: null,
prefFlips: null,
source: NimbusTelemetry.EnrollmentSource.FORCE_ENROLLMENT,
});
Assert.deepEqual(
dbEnrollments.optin.recipe.localizations,
{
"en-US": {
foo: "foo",
},
},
"optin: localizations is captured in recipe"
);
} catch (e) {
importMigrationError = e;
}
}
const { cleanup } = await setupTest({
storePath,
experiments,
secureExperiments,
migrations: {
[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: [
IMPORT_TO_SQL_MIGRATION,
{ name: "test-import-migration", fn: testImportMigration },
],
},
});
// NimbusMigrations swallows errors
if (importMigrationError) {
throw importMigrationError;
}
await NimbusTestUtils.cleanupManager([
"experiment-1",
"experiment-2",
"rollout-1",
"rollout-2",
"prefFlips-experiment",
"prefFlips-rollout",
"setPref-experiment",
"setPref-rollout",
"devtools",
"optin",
]);
await cleanup();
// On unenroll, we should 'reset' foo.bar.baz, nimbus.qa.pref-1, and nimbus.qa.pref-2.
Assert.equal(
Services.prefs.getStringPref("foo.bar.baz"),
"original-value",
"foo.bar.baz restored"
);
Assert.equal(
Services.prefs.getStringPref("nimbus.qa.pref-1"),
"original-value",
"nimbus.qs.pref-1 restored"
);
Assert.ok(
!Services.prefs.prefHasUserValue("nimbus.qa.pref-2"),
"nimbus.qa.pref-2 restored"
);
Services.prefs.deleteBranch("foo.bar.baz");
Services.prefs.deleteBranch("nimbus.qa.pref-1");
Services.prefs.deleteBranch("nimbus.qa.pref-2");
});