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:
@@ -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/",
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -38,8 +38,6 @@ run-sequentially = "very high failure rate in parallel"
|
||||
|
||||
["test_FirefoxLabs.js"]
|
||||
|
||||
["test_Migrations.js"]
|
||||
|
||||
["test_NimbusTestUtils.js"]
|
||||
|
||||
["test_RemoteSettingsExperimentLoader.js"]
|
||||
|
||||
Reference in New Issue
Block a user