Backed out 2 changesets (bug 1937147, bug 1937169) for causing multiple bc failures CLOSED TREE

Backed out changeset 1aa05a8bcf6f (bug 1937147)
Backed out changeset 2c17ed500261 (bug 1937169)
This commit is contained in:
Alexandru Marc
2025-03-01 08:29:15 +02:00
parent 39137ce75d
commit 7e7825a305
18 changed files with 460 additions and 1421 deletions

View File

@@ -76,6 +76,9 @@ var gExceptionPaths = [
// Page data schemas are referenced programmatically.
"chrome://browser/content/pagedata/schemas/",
// The consumer of this API hasn't landed yet. See bug 1937147.
"resource://nimbus/FirefoxLabs.sys.mjs",
// Nimbus schemas are referenced programmatically.
"resource://nimbus/schemas/",

View File

@@ -27,3 +27,7 @@
</hbox>
</html:div>
</html:template>
<html:template id="template-testFeatureGateExtra">
<html:div id="testFeatureGateExtraContent">Test extra content</html:div>
</html:template>

View File

@@ -4,223 +4,170 @@
/* import-globals-from preferences.js */
ChromeUtils.defineESModuleGetters(this, {
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
FirefoxLabs: "resource://nimbus/FirefoxLabs.sys.mjs",
});
const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed";
const gExperimentalPane = {
var gExperimentalPane = {
inited: false,
_featureGatesContainer: null,
_firefoxLabs: null,
_boundRestartObserver: null,
_observedPrefs: [],
_shouldPromptForRestart: true,
_featureGatePrefTypeToPrefServiceType(featureGatePrefType) {
if (featureGatePrefType != "boolean") {
throw new Error("Only boolean FeatureGates are supported");
}
return "bool";
},
async _observeRestart(aSubject, aTopic, aData) {
if (!this._shouldPromptForRestart) {
return;
}
let prefValue = Services.prefs.getBoolPref(aData);
let buttonIndex = await confirmRestartPrompt(prefValue, 1, true, false);
if (buttonIndex == CONFIRM_RESTART_PROMPT_RESTART_NOW) {
Services.startup.quit(
Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
);
return;
}
this._shouldPromptForRestart = false;
Services.prefs.setBoolPref(aData, !prefValue);
this._shouldPromptForRestart = true;
},
addPrefObserver(name, fn) {
this._observedPrefs.push({ name, fn });
Services.prefs.addObserver(name, fn);
},
removePrefObservers() {
for (let { name, fn } of this._observedPrefs) {
Services.prefs.removeObserver(name, fn);
}
this._observedPrefs = [];
},
// Reset the features to their default values
async resetAllFeatures() {
let features = await gExperimentalPane.getFeatures();
for (let feature of features) {
Services.prefs.setBoolPref(feature.preference, feature.defaultValue);
}
},
async getFeatures() {
let searchParams = new URLSearchParams(document.documentURIObject.query);
let definitionsUrl = searchParams.get("definitionsUrl");
let features = await FeatureGate.all(definitionsUrl);
return features.filter(f => f.isPublic);
},
async init() {
if (this.inited) {
return;
}
this.inited = true;
this._featureGatesContainer = document.getElementById(
"pane-experimental-featureGates"
);
this._onCheckboxChanged = this._onCheckboxChanged.bind(this);
this._onNimbusUpdate = this._onNimbusUpdate.bind(this);
this._onStudiesEnabledChanged = this._onStudiesEnabledChanged.bind(this);
this._resetAllFeatures = this._resetAllFeatures.bind(this);
setEventListener(
"experimentalCategory-reset",
"click",
this._resetAllFeatures
);
Services.obs.addObserver(
this._onStudiesEnabledChanged,
STUDIES_ENABLED_CHANGED
);
window.addEventListener("unload", () => this._removeObservers());
await this._maybeRenderLabsRecipes();
},
async _maybeRenderLabsRecipes() {
this._firefoxLabs = await FirefoxLabs.create();
const shouldHide = this._firefoxLabs.count === 0;
this._setCategoryVisibility(shouldHide);
if (shouldHide) {
return;
}
const frag = document.createDocumentFragment();
const groups = new Map();
for (const optIn of this._firefoxLabs.all()) {
if (!groups.has(optIn.firefoxLabsGroup)) {
groups.set(optIn.firefoxLabsGroup, []);
}
groups.get(optIn.firefoxLabsGroup).push(optIn);
}
for (const [group, optIns] of groups) {
const card = document.createElement("moz-card");
card.classList.add("featureGate");
const fieldset = document.createElement("moz-fieldset");
document.l10n.setAttributes(fieldset, group);
card.append(fieldset);
for (const optIn of optIns) {
const checkbox = document.createElement("moz-checkbox");
checkbox.dataset.nimbusSlug = optIn.slug;
checkbox.dataset.nimbusBranchSlug = optIn.branches[0].slug;
const description = document.createElement("div");
description.slot = "description";
description.id = `${optIn.slug}-description`;
description.classList.add("featureGateDescription");
for (const [key, value] of Object.entries(
optIn.firefoxLabsDescriptionLinks ?? {}
)) {
const link = document.createElement("a");
link.setAttribute("data-l10n-name", key);
link.setAttribute("href", value);
link.setAttribute("target", "_blank");
description.append(link);
}
document.l10n.setAttributes(description, optIn.firefoxLabsDescription);
checkbox.id = optIn.slug;
checkbox.setAttribute("aria-describedby", description.id);
document.l10n.setAttributes(checkbox, optIn.firefoxLabsTitle);
checkbox.checked =
ExperimentAPI._manager.store.get(optIn.slug)?.active ?? false;
checkbox.addEventListener("change", this._onCheckboxChanged);
checkbox.append(description);
fieldset.append(checkbox);
}
frag.append(card);
}
this._featureGatesContainer.appendChild(frag);
ExperimentAPI._manager.store.on("update", this._onNimbusUpdate);
Services.obs.notifyObservers(window, "experimental-pane-loaded");
},
_removeLabsRecipes() {
ExperimentAPI._manager.store.off("update", this._onNimbusUpdate);
this._featureGatesContainer
.querySelectorAll(".featureGate")
.forEach(el => el.remove());
},
async _onCheckboxChanged(event) {
const target = event.target;
const slug = target.dataset.nimbusSlug;
const branchSlug = target.dataset.nimbusBranchSlug;
const enrolling = !(
ExperimentAPI._manager.store.get(slug)?.active ?? false
);
let shouldRestart = false;
if (this._firefoxLabs.get(slug).requiresRestart) {
const buttonIndex = await confirmRestartPrompt(enrolling, 1, true, false);
shouldRestart = buttonIndex === CONFIRM_RESTART_PROMPT_RESTART_NOW;
if (!shouldRestart) {
// The user declined to restart, so we will not enroll in the opt-in.
target.checked = false;
return;
}
}
// Disable the checkbox so that the user cannot interact with it during enrollment.
target.disabled = true;
if (enrolling) {
await this._firefoxLabs.enroll(slug, branchSlug);
} else {
this._firefoxLabs.unenroll(slug);
}
target.disabled = false;
if (shouldRestart) {
Services.startup.quit(
Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
);
}
},
_onNimbusUpdate(_event, { slug, active }) {
if (this._firefoxLabs.get(slug)) {
document.getElementById(slug).checked = active;
}
},
async _onStudiesEnabledChanged() {
const studiesEnabled = ExperimentAPI._manager.studiesEnabled;
if (studiesEnabled) {
await this._maybeRenderLabsRecipes();
} else {
this._setCategoryVisibility(true);
this._removeLabsRecipes();
this._firefoxLabs = null;
}
},
_removeObservers() {
ExperimentAPI._manager.store.off("update", this._onNimbusUpdate);
Services.obs.removeObserver(
this._onStudiesEnabledChanged,
STUDIES_ENABLED_CHANGED
);
},
// Reset the features to their default values
async _resetAllFeatures() {
for (const optIn of this._firefoxLabs.all()) {
const enrolled =
(await ExperimentAPI._manager.store.get(optIn.slug)?.active) ?? false;
if (enrolled) {
this._firefoxLabs.unenroll(optIn.slug);
}
}
},
_setCategoryVisibility(shouldHide) {
let features = await this.getFeatures();
let shouldHide = !features.length;
document.getElementById("category-experimental").hidden = shouldHide;
document.getElementById("firefoxExperimentalCategory").hidden = shouldHide;
// Cache the visibility so we can show it quicker in subsequent loads.
Services.prefs.setBoolPref(
"browser.preferences.experimental.hidden",
shouldHide
);
if (
shouldHide &&
document.getElementById("categories").selectedItem?.id ==
if (shouldHide) {
// Remove the 'experimental' category if there are no available features
document.getElementById("firefoxExperimentalCategory").remove();
if (
document.getElementById("categories").selectedItem?.id ==
"category-experimental"
) {
// Leave the 'experimental' category if there are no available features
gotoPref("general");
) {
// Leave the 'experimental' category if there are no available features
gotoPref("general");
return;
}
}
setEventListener(
"experimentalCategory-reset",
"click",
gExperimentalPane.resetAllFeatures
);
window.addEventListener("unload", () => this.removePrefObservers());
this._featureGatesContainer = document.getElementById(
"pane-experimental-featureGates"
);
this._boundRestartObserver = this._observeRestart.bind(this);
let frag = document.createDocumentFragment();
let groups = new Map();
for (let feature of features) {
if (!groups.has(feature.group)) {
groups.set(feature.group, []);
}
groups.get(feature.group).push(feature);
}
for (let [group, groupFeatures] of groups) {
let card = document.createElement("moz-card");
card.classList.add("featureGate");
let fieldset = document.createElement("moz-fieldset");
document.l10n.setAttributes(fieldset, group);
card.append(fieldset);
for (let feature of groupFeatures) {
if (Preferences.get(feature.preference)) {
console.error(
"Preference control already exists for experimental feature '" +
feature.id +
"' with preference '" +
feature.preference +
"'"
);
continue;
}
if (feature.restartRequired) {
this.addPrefObserver(feature.preference, this._boundRestartObserver);
}
let checkbox = document.createElement("moz-checkbox");
let description = document.createElement("div");
description.slot = "description";
description.id = feature.id + "-description";
description.classList.add("featureGateDescription");
checkbox.append(description);
fieldset.append(checkbox);
let descriptionLinks = feature.descriptionLinks || {};
for (let [key, value] of Object.entries(descriptionLinks)) {
let link = document.createElement("a");
link.setAttribute("data-l10n-name", key);
link.setAttribute("href", value);
link.setAttribute("target", "_blank");
description.append(link);
}
document.l10n.setAttributes(description, feature.description);
checkbox.setAttribute("preference", feature.preference);
checkbox.id = feature.id;
checkbox.setAttribute("aria-describedby", description.id);
document.l10n.setAttributes(checkbox, feature.title);
let extraTemplate = document.getElementById(`template-${feature.id}`);
if (extraTemplate) {
fieldset.appendChild(extraTemplate.content.cloneNode(true));
}
let preference = Preferences.add({
id: feature.preference,
type: gExperimentalPane._featureGatePrefTypeToPrefServiceType(
feature.type
),
});
preference.setElementValue(checkbox);
}
frag.append(card);
}
this._featureGatesContainer.appendChild(frag);
Services.obs.notifyObservers(window, "experimental-pane-loaded");
},
};

View File

@@ -105,8 +105,6 @@ fail-if = ["a11y_checks"] # Bug 1854636 clicked treechildren#engineChildren may
["browser_experimental_features_resetall.js"]
["browser_experimental_features_studies_disabled.js"]
["browser_extension_controlled.js"]
skip-if = [
"tsan",

View File

@@ -3,11 +3,6 @@
"use strict";
add_setup(async function setup() {
const cleanup = await setupLabsTest();
registerCleanupFunction(cleanup);
});
add_task(async function testPrefRequired() {
await SpecialPowers.pushPrefEnv({
set: [["browser.preferences.experimental", false]],
@@ -21,8 +16,6 @@ add_task(async function testPrefRequired() {
ok(experimentalCategory.hidden, "The category is hidden");
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await SpecialPowers.popPrefEnv();
});
add_task(async function testCanOpenWithPref() {
@@ -53,8 +46,6 @@ add_task(async function testCanOpenWithPref() {
);
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await SpecialPowers.popPrefEnv();
});
add_task(async function testSearchFindsExperiments() {
@@ -80,6 +71,38 @@ add_task(async function testSearchFindsExperiments() {
);
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await SpecialPowers.popPrefEnv();
});
add_task(async function testExtraTemplate() {
await SpecialPowers.pushPrefEnv({
set: [["browser.preferences.experimental", true]],
});
// Pretend a feature has id of "featureGate" to reuse that template
const server = new DefinitionServer();
server.addDefinition({
id: "testFeatureGateExtra",
isPublicJexl: "true",
preference: "test.feature",
});
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
`about:preferences?definitionsUrl=${encodeURIComponent(
server.definitionsUrl
)}#paneExperimental`
);
const doc = gBrowser.contentDocument;
let extraContent = await TestUtils.waitForCondition(
() => doc.getElementById("testFeatureGateExtraContent"),
"wait for feature to get added to the DOM"
);
is(
extraContent.textContent,
"Test extra content",
"extra template added extra content"
);
BrowserTestUtils.removeTab(gBrowser.selectedTab);
});

View File

@@ -6,92 +6,78 @@
// This test verifies that searching filters the features to just that subset that
// contains the search terms.
add_task(async function testFilterFeatures() {
const recipes = [
{
...DEFAULT_LABS_RECIPES[0],
slug: "test-featureA",
},
{
...DEFAULT_LABS_RECIPES[1],
slug: "test-featureB",
firefoxLabsGroup: "experimental-features-group-customize-browsing",
},
{
...DEFAULT_LABS_RECIPES[2],
slug: "test-featureC",
targeting: "true",
firefoxLabsGroup: "experimental-features-group-customize-browsing",
},
{
...DEFAULT_LABS_RECIPES[3],
slug: "test-featureD",
bucketConfig: {
...ExperimentFakes.recipe.bucketConfig,
count: 1000,
},
},
];
const cleanup = await setupLabsTest(recipes);
await SpecialPowers.pushPrefEnv({
set: [["browser.preferences.experimental", true]],
});
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:preferences#paneExperimental"
);
const doc = gBrowser.contentDocument;
await TestUtils.waitForCondition(
() => doc.querySelector(".featureGate"),
"wait for the first public feature to get added to the DOM"
);
const definitions = [
// Add a number of test features.
const server = new DefinitionServer();
let definitions = [
{
id: "test-featureA",
preference: "test.featureA",
title: "Experimental Feature 1",
description: "This is a fun experimental feature you can enable",
group: "experimental-features-group-customize-browsing",
description: "This is a fun experimental feature you can enable",
result: true,
},
{
id: "test-featureB",
preference: "test.featureB",
title: "Experimental Thing 2",
group: "experimental-features-group-customize-browsing",
description: "This is a very boring experimental tool",
group: "experimental-features-group-webpage-display",
// Visible since it's grouped with other features that match the search.
result: true,
},
{
id: "test-featureC",
preference: "test.featureC",
title: "Experimental Thing 3",
group: "experimental-features-group-customize-browsing",
description: "This is a fun experimental feature for you can enable",
group: "experimental-features-group-developer-tools",
result: true,
},
{
id: "test-featureD",
preference: "test.featureD",
title: "Experimental Thing 4",
description: "This is not a checkbox that you should be enabling",
group: "experimental-features-group-developer-tools",
description: "This is a not a checkbox that you should be enabling",
// Doesn't match search and isn't grouped with a matching feature.
result: false,
},
];
for (let { id, preference, group } of definitions) {
server.addDefinition({ id, preference, isPublicJexl: "true", group });
}
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
`about:preferences?definitionsUrl=${encodeURIComponent(
server.definitionsUrl
)}#paneExperimental`
);
let doc = gBrowser.contentDocument;
await TestUtils.waitForCondition(
() => doc.getElementById(definitions[definitions.length - 1].id),
"wait for the first public feature to get added to the DOM"
);
// Manually modify the labels of the features that were just added, so that the test
// can rely on consistent search terms.
for (const definition of definitions) {
const mainItem = doc.getElementById(definition.id);
for (let definition of definitions) {
let mainItem = doc.getElementById(definition.id);
mainItem.label = definition.title;
mainItem.removeAttribute("data-l10n-id");
const descItem = doc.getElementById(`${definition.id}-description`);
let descItem = doc.getElementById(definition.id + "-description");
descItem.textContent = definition.description;
descItem.removeAttribute("data-l10n-id");
}
// First, check that all of the items are visible by default.
for (const definition of definitions) {
for (let definition of definitions) {
checkVisibility(
doc.getElementById(definition.id),
true,
@@ -102,7 +88,7 @@ add_task(async function testFilterFeatures() {
// After searching, only a subset should be visible.
await enterSearch(doc, "feature");
for (const definition of definitions) {
for (let definition of definitions) {
checkVisibility(
doc.getElementById(definition.id),
definition.result,
@@ -110,11 +96,11 @@ add_task(async function testFilterFeatures() {
definition.result ? "visible" : "hidden"
} after first search`
);
info(`Text for item was: ${doc.getElementById(definition.id).textContent}`);
info("Text for item was: " + doc.getElementById(definition.id).textContent);
}
// Reset the search entirely.
const searchInput = doc.getElementById("searchInput");
let searchInput = doc.getElementById("searchInput");
searchInput.value = "";
searchInput.doCommand();
@@ -125,7 +111,7 @@ add_task(async function testFilterFeatures() {
gBrowser.contentWindow
);
for (const definition of definitions) {
for (let definition of definitions) {
checkVisibility(
doc.getElementById(definition.id),
true,
@@ -135,10 +121,10 @@ add_task(async function testFilterFeatures() {
// Simulate entering a search and then clicking one of the category labels. The search
// should reset each time.
for (const category of ["category-search", "category-experimental"]) {
for (let category of ["category-search", "category-experimental"]) {
await enterSearch(doc, "feature");
for (const definition of definitions) {
for (let definition of definitions) {
checkVisibility(
doc.getElementById(definition.id),
definition.result,
@@ -161,8 +147,8 @@ add_task(async function testFilterFeatures() {
await new Promise(r =>
requestAnimationFrame(() => requestAnimationFrame(r))
);
const shouldShow = category == "category-experimental";
for (const definition of definitions) {
let shouldShow = category == "category-experimental";
for (let definition of definitions) {
checkVisibility(
doc.getElementById(definition.id),
shouldShow,
@@ -174,8 +160,6 @@ add_task(async function testFilterFeatures() {
}
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await cleanup();
});
function checkVisibility(element, expected, desc) {

View File

@@ -4,51 +4,49 @@
"use strict";
add_task(async function testNonPublicFeaturesShouldntGetDisplayed() {
const cleanup = await setupLabsTest();
await SpecialPowers.pushPrefEnv({
set: [["browser.preferences.experimental", true]],
});
const server = new DefinitionServer();
let definitions = [
{ id: "test-featureA", isPublicJexl: "true", preference: "test.feature.a" },
{
id: "test-featureB",
isPublicJexl: "false",
preference: "test.feature.b",
},
{ id: "test-featureC", isPublicJexl: "true", preference: "test.feature.c" },
];
for (let { id, isPublicJexl, preference } of definitions) {
server.addDefinition({ id, isPublicJexl, preference });
}
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:preferences#paneExperimental"
`about:preferences?definitionsUrl=${encodeURIComponent(
server.definitionsUrl
)}#paneExperimental`
);
let doc = gBrowser.contentDocument;
let firstPublicFeatureId = definitions.find(d => d.isPublicJexl == "true").id;
await TestUtils.waitForCondition(
() => doc.getElementById("nimbus-qa-1"),
"wait for features to be added to the DOM"
() => doc.getElementById(firstPublicFeatureId),
"wait for the first public feature to get added to the DOM"
);
Assert.ok(
!!doc.getElementById("nimbus-qa-1"),
"nimbus-qa-1 checkbox in the document"
);
Assert.ok(
!!doc.getElementById("nimbus-qa-2"),
"nimbus-qa-2 checkbox in the document"
);
Assert.ok(
!doc.getElementById("targeting-false"),
"targeting-false checkbox not in the document"
);
Assert.ok(
!doc.getElementById("bucketing-false"),
"bucketing-false checkbox not in the document"
);
for (let definition of definitions) {
is(
!!doc.getElementById(definition.id),
definition.isPublicJexl == "true",
"feature should only be in DOM if it's public: " + definition.id
);
}
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await cleanup();
await SpecialPowers.popPrefEnv();
});
add_task(async function testNonPublicFeaturesShouldntGetDisplayed() {
// Only recipes that do not match targeting or bucketing
const cleanup = await setupLabsTest(DEFAULT_LABS_RECIPES.slice(2));
await SpecialPowers.pushPrefEnv({
set: [
["browser.preferences.experimental", true],
@@ -56,11 +54,19 @@ add_task(async function testNonPublicFeaturesShouldntGetDisplayed() {
],
});
const server = new DefinitionServer();
server.addDefinition({
id: "test-hidden",
isPublicJexl: "false",
preference: "test.feature.hidden",
});
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:preferences#paneExperimental"
`about:preferences?definitionsUrl=${encodeURIComponent(
server.definitionsUrl
)}#paneExperimental`
);
const doc = gBrowser.contentDocument;
let doc = gBrowser.contentDocument;
await TestUtils.waitForCondition(
() => doc.getElementById("category-experimental").hidden,
@@ -72,8 +78,8 @@ add_task(async function testNonPublicFeaturesShouldntGetDisplayed() {
"Experimental Features section should be hidden when all features are hidden"
);
ok(
doc.getElementById("firefoxExperimentalCategory").hidden,
"Experimental Features header should be hidden when all features are hidden"
!doc.getElementById("firefoxExperimentalCategory"),
"Experimental Features header should not exist when all features are hidden"
);
is(
doc.querySelector(".category[selected]").id,
@@ -82,7 +88,4 @@ add_task(async function testNonPublicFeaturesShouldntGetDisplayed() {
);
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await cleanup();
await SpecialPowers.popPrefEnv();
});

View File

@@ -3,77 +3,153 @@
"use strict";
const { FirefoxLabs } = ChromeUtils.importESModule(
"resource://nimbus/FirefoxLabs.sys.mjs"
);
add_setup(async function setup() {
const cleanup = await setupLabsTest();
registerCleanupFunction(cleanup);
});
// It doesn't matter what two preferences are used here, as long as the first is a built-in
// one that defaults to false and the second defaults to true.
const KNOWN_PREF_1 = "browser.display.use_system_colors";
const KNOWN_PREF_2 = "browser.autofocus";
// This test verifies that pressing the reset all button for experimental features
// resets all of the checkboxes to their default state.
add_task(async function testResetAll() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.preferences.experimental", true],
["test.featureA", false],
["test.featureB", true],
[KNOWN_PREF_1, false],
[KNOWN_PREF_2, true],
],
});
// Add a number of test features.
const server = new DefinitionServer();
let definitions = [
{
id: "test-featureA",
preference: "test.featureA",
defaultValueJexl: "false",
},
{
id: "test-featureB",
preference: "test.featureB",
defaultValueJexl: "true",
},
{
id: "test-featureC",
preference: KNOWN_PREF_1,
defaultValueJexl: "false",
},
{
id: "test-featureD",
preference: KNOWN_PREF_2,
defaultValueJexl: "true",
},
];
for (let { id, preference, defaultValueJexl } of definitions) {
server.addDefinition({
id,
preference,
defaultValueJexl,
isPublicJexl: "true",
});
}
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
`about:preferences#paneExperimental`
`about:preferences?definitionsUrl=${encodeURIComponent(
server.definitionsUrl
)}#paneExperimental`
);
const doc = gBrowser.contentDocument;
let doc = gBrowser.contentDocument;
await TestUtils.waitForCondition(
() => doc.querySelector(".featureGate"),
"wait for features to be added to the DOM"
() => doc.getElementById(definitions[definitions.length - 1].id),
"wait for the first public feature to get added to the DOM"
);
const qa1El = doc.getElementById("nimbus-qa-1");
const qa2El = doc.getElementById("nimbus-qa-2");
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-1")?.active,
"Should not enroll in nimbus-qa-1"
);
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-2")?.active,
"Should not enroll in nimbus-qa-2"
);
ok(!qa1El.checked, "nimbus-qa-1 checkbox unchecked");
ok(!qa2El.checked, "nimbus-qa-2 checkbox unchecked");
// Check the initial state of each feature.
ok(!Services.prefs.getBoolPref("test.featureA"), "initial state A");
ok(Services.prefs.getBoolPref("test.featureB"), "initial state B");
ok(!Services.prefs.getBoolPref(KNOWN_PREF_1), "initial state C");
ok(Services.prefs.getBoolPref(KNOWN_PREF_2), "initial state D");
// Modify the state of some of the features.
await enrollByClick(qa1El, true);
await enrollByClick(qa2El, true);
ok(
ExperimentAPI._manager.store.get("nimbus-qa-1")?.active,
"Should enroll in nimbus-qa-1"
EventUtils.synthesizeMouseAtCenter(
doc.getElementById("test-featureC").inputEl,
{},
gBrowser.contentWindow
);
ok(
ExperimentAPI._manager.store.get("nimbus-qa-2")?.active,
"Should enroll in nimbus-qa-2"
EventUtils.synthesizeMouseAtCenter(
doc.getElementById("test-featureD").labelEl,
{},
gBrowser.contentWindow
);
ok(qa1El.checked, "nimbus-qa-1 checkbox checked");
ok(qa2El.checked, "nimbus-qa-2 checkbox checked");
// Verify that clicking the non-interactive description does not
// trigger telemetry events.
AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
EventUtils.synthesizeMouseAtCenter(
doc.getElementById("test-featureD").descriptionEl,
{},
gBrowser.contentWindow
);
AccessibilityUtils.resetEnv();
const unenrollPromises = [
promiseNimbusStoreUpdate("nimbus-qa-1", false),
promiseNimbusStoreUpdate("nimbus-qa-2", false),
];
// Check the prefs changed
ok(!Services.prefs.getBoolPref("test.featureA"), "modified state A");
ok(Services.prefs.getBoolPref("test.featureB"), "modified state B");
ok(Services.prefs.getBoolPref(KNOWN_PREF_1), "modified state C");
ok(!Services.prefs.getBoolPref(KNOWN_PREF_2), "modified state D");
// Check that telemetry appeared:
const { TelemetryTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/TelemetryTestUtils.sys.mjs"
);
let snapshot = TelemetryTestUtils.getProcessScalars("parent", true, true);
TelemetryTestUtils.assertKeyedScalar(
snapshot,
"browser.ui.interaction.preferences_paneExperimental",
"test-featureC",
1
);
TelemetryTestUtils.assertKeyedScalar(
snapshot,
"browser.ui.interaction.preferences_paneExperimental",
"test-featureD",
1
);
// State after reset.
let prefChangedPromise = new Promise(resolve => {
Services.prefs.addObserver(KNOWN_PREF_2, function observer() {
Services.prefs.removeObserver(KNOWN_PREF_2, observer);
resolve();
});
});
doc.getElementById("experimentalCategory-reset").click();
await Promise.all(unenrollPromises);
await prefChangedPromise;
// The preferences will be reset to the default value for the feature.
ok(!Services.prefs.getBoolPref("test.featureA"), "after reset state A");
ok(Services.prefs.getBoolPref("test.featureB"), "after reset state B");
ok(!Services.prefs.getBoolPref(KNOWN_PREF_1), "after reset state C");
ok(Services.prefs.getBoolPref(KNOWN_PREF_2), "after reset state D");
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-1")?.active,
"Should unenroll from nimbus-qa-1"
!doc.getElementById("test-featureA").checked,
"after reset checkbox state A"
);
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-2")?.active,
"Should unenroll from nimbus-qa-2"
doc.getElementById("test-featureB").checked,
"after reset checkbox state B"
);
ok(
!doc.getElementById("test-featureC").checked,
"after reset checkbox state C"
);
ok(
doc.getElementById("test-featureD").checked,
"after reset checkbox state D"
);
ok(!qa1El.checked, "nimbus-qa-1 checkbox unchecked");
ok(!qa2El.checked, "nimbus-qa-2 checkbox unchecked");
BrowserTestUtils.removeTab(gBrowser.selectedTab);
});

View File

@@ -1,155 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_task(async function testHiddenWhenStudiesDisabled() {
const cleanup = await setupLabsTest();
await SpecialPowers.pushPrefEnv({
set: [
["browser.preferences.experimental", true],
["browser.preferences.experimental.hidden", false],
],
});
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:preferences#paneExperimental"
);
const doc = gBrowser.contentDocument;
await waitForExperimentalFeaturesShown(doc);
await enrollByClick(doc.getElementById("nimbus-qa-1"), true);
await enrollByClick(doc.getElementById("nimbus-qa-2"), true);
// Disabling studies should remove the experimental pane.
await SpecialPowers.pushPrefEnv({
set: [["app.shield.optoutstudies.enabled", false]],
});
await waitForExperimentalFeaturesHidden(doc);
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-1")?.active,
"Should unenroll from nimbus-qa-1"
);
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-2")?.active,
"Should unenroll from nimbus-qa-2"
);
// Re-enabling studies should re-add it.
await SpecialPowers.popPrefEnv();
await waitForExperimentalFeaturesShown(doc);
// Navigate back to the experimental tab.
EventUtils.synthesizeMouseAtCenter(
doc.getElementById("category-experimental"),
{},
gBrowser.contentWindow
);
await waitForPageFlush();
is(
doc.querySelector(".category[selected]").id,
"category-experimental",
"Experimental category selected"
);
ok(
!doc.getElementById("nimbus-qa-1").checked,
"nimbus-qa-1 checkbox unchecked"
);
ok(
!doc.getElementById("nimbus-qa-2").checked,
"nimbus-qa-2 checkbox unchecked"
);
await enrollByClick(doc.getElementById("nimbus-qa-1"), true);
await enrollByClick(doc.getElementById("nimbus-qa-2"), true);
// Likewise, disabling telemetry should remove the experimental pane.
await SpecialPowers.pushPrefEnv({
set: [["datareporting.healthreport.uploadEnabled", false]],
});
await waitForExperimentalFeaturesHidden(doc);
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-1")?.active,
"Should unenroll from nimbus-qa-1"
);
ok(
!ExperimentAPI._manager.store.get("nimbus-qa-2")?.active,
"Should unenroll from nimbus-qa-2"
);
await SpecialPowers.popPrefEnv();
// Re-enabling studies should re-add it.
await waitForExperimentalFeaturesShown(doc);
ok(
!doc.getElementById("nimbus-qa-1").checked,
"nimbus-qa-1 checkbox unchecked"
);
ok(
!doc.getElementById("nimbus-qa-2").checked,
"nimbus-qa-2 checkbox unchecked"
);
BrowserTestUtils.removeTab(gBrowser.selectedTab);
await cleanup();
await SpecialPowers.popPrefEnv();
});
async function waitForExperimentalFeaturesShown(doc) {
await TestUtils.waitForCondition(
() => doc.querySelector(".featureGate"),
"Wait for features to be added to the DOM"
);
ok(
!doc.getElementById("firefoxExperimentalCategory").hidden,
"Experimental Features header should not be hidden"
);
ok(
!Services.prefs.getBoolPref("browser.preferences.experimental.hidden"),
"Hidden pref should be false"
);
}
async function waitForExperimentalFeaturesHidden(doc) {
await TestUtils.waitForCondition(
() => doc.getElementById("category-experimental").hidden,
"Wait for Experimental Features section to get hidden"
);
ok(
doc.getElementById("category-experimental").hidden,
"Experimental Features section should be hidden when all features are hidden"
);
ok(
doc.getElementById("firefoxExperimentalCategory").hidden,
"Experimental Features header should be hidden when all features are hidden"
);
is(
doc.querySelector(".category[selected]").id,
"category-general",
"When the experimental features section is hidden, navigating to #experimental should redirect to #general"
);
ok(
Services.prefs.getBoolPref("browser.preferences.experimental.hidden"),
"Hidden pref should be true"
);
}
function waitForPageFlush() {
return new Promise(resolve =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
);
}

View File

@@ -13,11 +13,6 @@ ChromeUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => {
return module;
});
ChromeUtils.defineESModuleGetters(this, {
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
});
const kDefaultWait = 2000;
function is_element_visible(aElement, aMsg) {
@@ -181,6 +176,60 @@ function waitForMutation(target, opts, cb) {
});
}
// Used to add sample experimental features for testing. To use, create
// a DefinitionServer, then call addDefinition as needed.
class DefinitionServer {
constructor(definitionOverrides = []) {
let { HttpServer } = ChromeUtils.importESModule(
"resource://testing-common/httpd.sys.mjs"
);
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.write(JSON.stringify(this.definitions));
}
get definitionsUrl() {
const { primaryScheme, primaryHost, primaryPort } = this.server.identity;
return `${primaryScheme}://${primaryHost}:${primaryPort}/definitions.json`;
}
addDefinition(overrides = {}) {
const definition = {
id: "test-feature",
// These l10n IDs are just random so we have some text to display
title: "experimental-features-media-jxl",
group: "experimental-features-group-customize-browsing",
description: "pane-experimental-description3",
restartRequired: false,
type: "boolean",
preference: "test.feature",
defaultValue: false,
isPublic: false,
...overrides,
};
// convert targeted values, used by fromId
definition.isPublic = { default: definition.isPublic };
definition.defaultValue = { default: definition.defaultValue };
this.definitions[definition.id] = definition;
return definition;
}
}
/**
* Creates observer that waits for and then compares all perm-changes with the observances in order.
* @param {Array} observances permission changes to observe (order is important)
@@ -401,158 +450,3 @@ async function assertSuggestVisibility(expectedByElementId) {
}
}
}
const DEFAULT_LABS_RECIPES = [
ExperimentFakes.recipe("nimbus-qa-1", {
bucketConfig: {
...ExperimentFakes.recipe.bucketConfig,
count: 1000,
},
targeting: "true",
isRollout: true,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "experimental-features-auto-pip",
firefoxLabsDescription: "experimental-features-auto-pip-description",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "experimental-features-group-customize-browsing",
requiresRestart: false,
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "nimbus-qa-1",
value: {
value: "recipe-value-1",
},
},
],
},
],
}),
ExperimentFakes.recipe("nimbus-qa-2", {
bucketConfig: {
...ExperimentFakes.recipe.bucketConfig,
count: 1000,
},
targeting: "true",
isRollout: true,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "experimental-features-media-jxl",
firefoxLabsDescription: "experimental-features-media-jxl-description",
firefoxLabsDescriptionLinks: {
bugzilla: "https://example.com",
},
firefoxLabsGroup: "experimental-features-group-webpage-display",
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "nimbus-qa-2",
value: {
value: "recipe-value-2",
},
},
],
},
],
}),
ExperimentFakes.recipe("targeting-false", {
bucketConfig: {
...ExperimentFakes.recipe.bucketConfig,
count: 1000,
},
targeting: "false",
isRollout: true,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "experimental-features-ime-search",
firefoxLabsDescription: "experimental-features-ime-search-description",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "experimental-features-group-developer-tools",
requiresRestart: false,
}),
ExperimentFakes.recipe("bucketing-false", {
bucketConfig: {
...ExperimentFakes.recipe.bucketConfig,
count: 0,
},
targeting: "true",
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "experimental-features-ime-search",
firefoxLabsDescription: "experimental-features-ime-search-description",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "experimental-features-group-developer-tools",
requiresRestart: false,
}),
];
async function setupLabsTest(recipes) {
await SpecialPowers.pushPrefEnv({
set: [
["app.normandy.run_interval_seconds", 0],
["app.shield.optoutstudies.enabled", true],
["datareporting.healthreport.uploadEnabled", true],
["messaging-system.log", "debug"],
],
});
// Initialize Nimbus and wait for the RemoteSettingsExperimentLoader to finish
// updating (with no recipes).
await ExperimentAPI.ready();
await ExperimentAPI._rsLoader.finishedUpdating();
// Inject some recipes into the Remote Settings client and call
// updateRecipes() so that we have available opt-ins.
await ExperimentAPI._rsLoader.remoteSettingsClients.experiments.db.importChanges(
{},
Date.now(),
recipes ?? DEFAULT_LABS_RECIPES,
{ clear: true }
);
await ExperimentAPI._rsLoader.updateRecipes("test");
return async function cleanup() {
const store = ExperimentAPI._manager.store;
store._store._saver.disarm();
if (store._store._saver.isRunning) {
await store._store._saver._runningPromise;
}
await IOUtils.remove(store._store.path);
await SpecialPowers.popPrefEnv();
};
}
function promiseNimbusStoreUpdate(wantedSlug, wantedActive) {
const deferred = Promise.withResolvers();
const listener = (_event, { slug, active }) => {
info(
`promiseNimbusStoreUpdate: received update for ${slug} active=${active}`
);
if (slug === wantedSlug && active === wantedActive) {
ExperimentAPI._manager.store.off("update", listener);
deferred.resolve();
}
};
ExperimentAPI._manager.store.on("update", listener);
return deferred.promise;
}
function enrollByClick(el, wantedActive) {
const slug = el.dataset.nimbusSlug;
info(`Enrolling in ${slug}:${el.dataset.nimbusBranchSlug}...`);
const promise = promiseNimbusStoreUpdate(slug, wantedActive);
EventUtils.synthesizeMouseAtCenter(el.inputEl, {}, gBrowser.contentWindow);
return promise;
}

View File

@@ -11,7 +11,6 @@ ChromeUtils.defineESModuleGetters(lazy, {
CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
FeatureManifest: "resource://nimbus/FeatureManifest.sys.mjs",
NimbusMigrations: "resource://nimbus/lib/Migrations.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
RemoteSettingsExperimentLoader:
"resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
@@ -112,12 +111,6 @@ export const ExperimentAPI = {
lazy.log.error("Failed to enable RemoteSettingsExperimentLoader:", e);
}
try {
await lazy.NimbusMigrations.applyMigrations();
} catch (e) {
lazy.log.error("Failed to apply migrations", e);
}
if (CRASHREPORTER_ENABLED) {
this._manager.store.on("update", this._annotateCrashReport);
this._annotateCrashReport();

View File

@@ -101,15 +101,6 @@ export class FirefoxLabs {
}
}
/**
* Return the number of eligible opt-ins.
*
* @return {number} The number of eligible opt-ins.
*/
get count() {
return this.#recipes.size;
}
/**
* Yield all available opt-ins.
*
@@ -120,15 +111,4 @@ export class FirefoxLabs {
yield recipe;
}
}
/**
* Return an opt-in by its slug
*
* @param {string} slug The slug of the opt-in to return.
*
* @returns {object} The requested opt-in, if it exists.
*/
get(slug) {
return this.#recipes.get(slug);
}
}

View File

@@ -483,23 +483,20 @@ export class _ExperimentManager {
// RemoteSettingsExperimentLoader should have finished updating at least
// once. Prevent concurrent updates while we filter through the list of
// available opt-in recipes.
return lazy.ExperimentAPI._rsLoader.withUpdateLock(
async () => {
const filtered = [];
return lazy.ExperimentAPI._rsLoader.withUpdateLock(async () => {
const filtered = [];
for (const recipe of this.optInRecipes) {
if (
(await enrollmentsCtx.checkTargeting(recipe)) &&
(await this.isInBucketAllocation(recipe.bucketConfig))
) {
filtered.push(recipe);
}
for (const recipe of this.optInRecipes) {
if (
(await enrollmentsCtx.checkTargeting(recipe)) &&
(await this.isInBucketAllocation(recipe.bucketConfig))
) {
filtered.push(recipe);
}
}
return filtered;
},
{ mode: "shared" }
);
return filtered;
});
}
/**
@@ -973,8 +970,6 @@ export class _ExperimentManager {
for (const { slug } of this.store.getAllActiveRollouts()) {
this.unenroll(slug, "studies-opt-out");
}
this.optInRecipes = [];
}
/**

View File

@@ -1,171 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
FirefoxLabs: "resource://nimbus/FirefoxLabs.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "log", () => {
const { Logger } = ChromeUtils.importESModule(
"resource://messaging-system/lib/Logger.sys.mjs"
);
return new Logger("NimbusMigrations");
});
function migration(name, fn) {
return { name, fn };
}
export const NIMBUS_MIGRATION_PREF = "nimbus.migrations.latest";
export const LABS_MIGRATION_FEATURE_MAP = {
"auto-pip": "firefox-labs-auto-pip",
"urlbar-ime-search": "firefox-labs-urlbar-ime-search",
"jpeg-xl": "firefox-labs-jpeg-xl",
};
async function migrateFirefoxLabsEnrollments() {
await lazy.ExperimentAPI._rsLoader.finishedUpdating();
await lazy.ExperimentAPI._rsLoader.withUpdateLock(
async () => {
const labs = await lazy.FirefoxLabs.create();
let didEnroll = false;
for (const [feature, slug] of Object.entries(
LABS_MIGRATION_FEATURE_MAP
)) {
const pref =
lazy.NimbusFeatures[feature].manifest.variables.enabled.setPref.pref;
if (!labs.get(slug)) {
// If the recipe is not available then either it is no longer live or
// the targeting did not match.
continue;
}
if (!Services.prefs.getBoolPref(pref, false)) {
// Only enroll if the migration pref is set.
continue;
}
await labs.enroll(slug, "control");
// We need to overwrite the original pref value stored in the
// ExperimentStore so that unenrolling will disable the feature.
const enrollment = lazy.ExperimentAPI._manager.store.get(slug);
if (!enrollment) {
lazy.log.error(`Enrollment with ${slug} should exist but does not`);
continue;
}
if (!enrollment.active) {
lazy.log.error(
`Enrollment with slug ${slug} should be active but is not.`
);
continue;
}
const prefEntry = enrollment.prefs?.find(entry => entry.name === pref);
if (!prefEntry) {
lazy.log.error(
`Enrollment with slug ${slug} does not set pref ${pref}`
);
continue;
}
didEnroll = true;
prefEntry.originalValue = false;
}
if (didEnroll) {
// Trigger a save of the ExperimentStore since we've changed some data
// structures without using set().
// We do not have to sync these changes to child processes because the
// data is only used in the parent process.
lazy.ExperimentAPI._manager.store._store.saveSoon();
}
},
{ mode: "shared" }
);
}
export class MigrationError extends Error {
static Reason = Object.freeze({
UNKNOWN: "unknown",
});
constructor(reason) {
super(`Migration error: ${reason}`);
this.reason = reason;
}
}
export const NimbusMigrations = {
migration,
/**
* Apply any outstanding migrations.
*/
async applyMigrations() {
const latestMigration = Services.prefs.getIntPref(
NIMBUS_MIGRATION_PREF,
-1
);
let lastSuccess = latestMigration;
lazy.log.debug(`applyMigrations: latestMigration = ${latestMigration}`);
for (let i = latestMigration + 1; i < this.MIGRATIONS.length; i++) {
const migration = this.MIGRATIONS[i];
lazy.log.debug(
`applyMigrations: applying migration ${i}: ${migration.name}`
);
try {
await migration.fn();
} catch (e) {
lazy.log.error(
`applyMigrations: error running migration ${i} (${migration.name}): ${e}`
);
let reason = MigrationError.Reason.UNKNOWN;
if (e instanceof MigrationError) {
reason = e.reason;
}
Glean.nimbusEvents.migration.record({
migration_id: migration.name,
success: false,
error_reason: reason,
});
break;
}
lastSuccess = i;
lazy.log.debug(
`applyMigrations: applied migration ${i}: ${migration.name}`
);
Glean.nimbusEvents.migration.record({
migration_id: migration.name,
success: true,
});
}
if (latestMigration != lastSuccess) {
Services.prefs.setIntPref(NIMBUS_MIGRATION_PREF, lastSuccess);
}
},
MIGRATIONS: [
migration("firefox-labs-enrollments", migrateFirefoxLabsEnrollments),
],
};

View File

@@ -176,14 +176,7 @@ export class _RemoteSettingsExperimentLoader {
async enable(options = {}) {
const { forceSync = false } = options;
if (this._enabled) {
return;
}
if (!this.studiesEnabled) {
lazy.log.debug(
"Not enabling RemoteSettingsExperimentLoader: studies disabled"
);
if (this._enabled || !this.studiesEnabled) {
return;
}
@@ -382,8 +375,9 @@ export class _RemoteSettingsExperimentLoader {
);
} catch (e) {
lazy.log.debug(
`Error getting recipes from Remote Settings collection ${client.collectionName}: ${e}`
`Error getting recipes from Remote Settings collection ${client.collectionName}`
);
console.error(e);
return null;
}
@@ -567,14 +561,8 @@ export class _RemoteSettingsExperimentLoader {
/**
* Resolves when the RemoteSettingsExperimentLoader has updated at least once
* and is not in the middle of an update.
*
* If studies are disabled, then this will always resolve immediately.
*/
finishedUpdating() {
if (!this.studiesEnabled) {
return Promise.resolve();
}
return this._updatingDeferred.promise;
}
}

View File

@@ -693,7 +693,7 @@ nimbus_events:
description: The branch slug/identifier that was randomly chosen
experiment_type:
type: string
description: Indicates whether this is an experiment or rollout
description: Indicates whether this is an experiemnt or rollout
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1773563
- https://bugzilla.mozilla.org/show_bug.cgi?id=1781953
@@ -951,44 +951,6 @@ nimbus_events:
expires: never
disabled: true
migration:
type: event
description: >
Triggered whenever a Nimbus migration is attempted, whether or not it succeeds.
extra_keys:
migration_id:
type: string
description: >
The name of the migration that ran.
success:
type: boolean
description: Whether or not the migration succeeded.
error_reason:
type: string
description: >
A string describing the error that occurred.
Reasons include:
- "unknown": an unknown exception occurred.
enrollments:
type: string
description: >
An optional string that includes any enrollments triggered by this relation
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1937169
data_reviews:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1937169
data_sensitivity:
- technical
notification_emails:
- beth@mozilla.com
- project-nimbus@mozilla.com
expires: never
normandy:
expose_nimbus_experiment:
type: event

View File

@@ -1,483 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const { ExperimentAPI, NimbusFeatures } = ChromeUtils.importESModule(
"resource://nimbus/ExperimentAPI.sys.mjs"
);
const {
LABS_MIGRATION_FEATURE_MAP,
MigrationError,
NIMBUS_MIGRATION_PREF,
NimbusMigrations,
} = ChromeUtils.importESModule("resource://nimbus/lib/Migrations.sys.mjs");
function mockLabsRecipes(targeting = "true") {
return Object.entries(LABS_MIGRATION_FEATURE_MAP).map(([featureId, slug]) =>
ExperimentFakes.recipe(slug, {
isRollout: true,
isFirefoxLabsOptIn: true,
firefoxLabsTitle: `${featureId}-placeholder-title`,
firefoxLabsDescription: `${featureId}-placeholder-desc`,
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "placeholder",
bucketConfig: {
...ExperimentFakes.recipe.bucketConfig,
count: 1000,
},
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId,
value: {
enabled: true,
},
},
],
},
],
targeting,
})
);
}
function getEnabledPrefForFeature(featureId) {
return NimbusFeatures[featureId].manifest.variables.enabled.setPref.pref;
}
function removeExperimentManagerListeners(manager) {
// This is a giant hack to remove pref listeners from the global ExperimentManager or an
// ExperimentManager from a previous test (because the nsIObserverService holds a strong reference
// to all these listeners);
//
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1950237 for a long-term solution to this.
Services.prefs.removeObserver(
"datareporting.healthreport.uploadEnabled",
manager
);
Services.prefs.removeObserver("app.shield.optoutstudies.enabled", manager);
}
add_setup(function setup() {
do_get_profile();
Services.fog.initializeFOG();
removeExperimentManagerListeners(ExperimentAPI._manager);
});
/**
* Setup a test environment.
*
* @param {object} options
* @param {number?} options.latestMigration
* The value that should be set for the latest Nimbus migration
* pref. If not provided, the pref will be unset.
* @param {object[]} 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 {boolean} options.init
* If true, the ExperimentAPI will be initialized during the setup.
*/
async function setupTest({
latestMigration,
migrations,
recipes,
init = true,
} = {}) {
const sandbox = sinon.createSandbox();
const loader = ExperimentFakes.rsLoader();
sandbox.stub(ExperimentAPI, "_rsLoader").get(() => loader);
sandbox.stub(ExperimentAPI, "_manager").get(() => loader.manager);
sandbox.stub(loader, "setTimer");
Assert.ok(
!Services.prefs.prefHasUserValue(NIMBUS_MIGRATION_PREF),
"migration pref should be unset"
);
if (typeof latestMigration !== "undefined") {
Services.prefs.setIntPref(NIMBUS_MIGRATION_PREF, latestMigration);
}
if (Array.isArray(migrations)) {
sandbox.stub(NimbusMigrations, "MIGRATIONS").get(() => migrations);
}
if (Array.isArray(recipes)) {
sandbox
.stub(loader.remoteSettingsClients.experiments, "get")
.resolves(recipes);
}
if (init) {
await ExperimentAPI.init();
await ExperimentAPI.ready();
}
return {
sandbox,
loader,
manager: loader.manager,
async cleanup({ removeStore = false } = {}) {
await assertEmptyStore(loader.manager.store, { cleanup: removeStore });
ExperimentAPI._resetForTests();
removeExperimentManagerListeners(loader.manager);
Services.prefs.deleteBranch(NIMBUS_MIGRATION_PREF);
sandbox.restore();
Services.fog.testResetFOG();
},
};
}
function makeMigrations(count) {
const migrations = [];
for (let i = 0; i < count; i++) {
migrations.push(
NimbusMigrations.migration(`test-migration-${i}`, sinon.stub())
);
}
return migrations;
}
add_task(async function test_migration_unset() {
info("Testing NimbusMigrations with no migration pref set");
const migrations = makeMigrations(2);
const { cleanup } = await setupTest({ migrations });
Assert.ok(
migrations[0].fn.calledOnce,
`${migrations[0].name} should be called once`
);
Assert.ok(
migrations[1].fn.calledOnce,
`${migrations[1].name} should be called once`
);
Assert.equal(
Services.prefs.getIntPref(NIMBUS_MIGRATION_PREF),
1,
"Migration pref should be updated"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: migrations[0].name,
},
{
success: "true",
migration_id: migrations[1].name,
},
]
);
await cleanup();
});
add_task(async function test_migration_partially_done() {
info("Testing NimbusMigrations with some migrations completed");
const migrations = makeMigrations(2);
const { cleanup } = await setupTest({ latestMigration: 0, migrations });
Assert.ok(
migrations[0].fn.notCalled,
`${migrations[0].name} should not be called`
);
Assert.ok(
migrations[1].fn.calledOnce,
`${migrations[1].name} should be called once`
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: migrations[1].name,
},
]
);
await cleanup();
});
add_task(async function test_migration_throws() {
info(
"Testing NimbusMigrations with a migration that throws an unknown error"
);
const migrations = makeMigrations(3);
migrations[1].fn.throws(new Error(`${migrations[1].name} failed`));
const { cleanup } = await setupTest({ migrations });
Assert.ok(
migrations[0].fn.calledOnce,
`${migrations[0].name} should be called once`
);
Assert.ok(
migrations[1].fn.calledOnce,
`${migrations[1].name} should be called once`
);
Assert.ok(
migrations[2].fn.notCalled,
`${migrations[2].name} should not be called`
);
Assert.equal(
Services.prefs.getIntPref(NIMBUS_MIGRATION_PREF),
0,
"Migration pref should only be set to 0"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: migrations[0].name,
},
{
success: "false",
migration_id: migrations[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 migrations = makeMigrations(3);
migrations[1].fn.throws(new MigrationError("bogus"));
const { cleanup } = await setupTest({ migrations });
Assert.ok(
migrations[0].fn.calledOnce,
`${migrations[0].name} should be called once`
);
Assert.ok(
migrations[1].fn.calledOnce,
`${migrations[1].name} should be called once`
);
Assert.ok(
migrations[2].fn.notCalled,
`${migrations[2].name} should not be called`
);
Assert.equal(
Services.prefs.getIntPref(NIMBUS_MIGRATION_PREF),
0,
"Migration pref should only be set to 0"
);
Assert.deepEqual(
Glean.nimbusEvents.migration.testGetValue().map(event => event.extra),
[
{
success: "true",
migration_id: migrations[0].name,
},
{
success: "false",
migration_id: migrations[1].name,
error_reason: "bogus",
},
]
);
await cleanup();
});
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({
recipes: mockLabsRecipes("true"),
});
Assert.deepEqual(
await ExperimentAPI._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 metadata = ExperimentAPI.getRolloutMetaData({ slug });
if (enrollmentExpected) {
Assert.ok(!!metadata, `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`
);
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(
!metadata,
`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({
recipes: mockLabsRecipes("false"),
});
Assert.deepEqual(
await ExperimentAPI._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 = ExperimentFakes.manager();
await manager.onStartup();
manager.enroll(recipes[0], "rs-loader", { branchSlug: "control" });
await manager.store._store.saveSoon();
await manager.store._store.finalize();
removeExperimentManagerListeners(manager);
}
const { manager, cleanup } = await setupTest({
recipes,
});
Assert.equal(
Services.prefs.getIntPref(NIMBUS_MIGRATION_PREF),
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) {
manager.unenroll(slug);
}
await cleanup({ removeStore: true });
for (const pref of prefs) {
Services.prefs.clearUserPref(pref);
}
});

View File

@@ -38,8 +38,6 @@ run-sequentially = "very high failure rate in parallel"
["test_FirefoxLabs.js"]
["test_Migrations.js"]
["test_NimbusTestUtils.js"]
["test_RemoteSettingsExperimentLoader.js"]