Bug 1624728 - Messaging Experiments should show up in about:studies r=mythmon

Differential Revision: https://phabricator.services.mozilla.com/D81711
This commit is contained in:
Andrei Oprea 2020-07-06 08:33:13 +00:00
Родитель 402b0a5c46
Коммит 85c0304595
9 изменённых файлов: 247 добавлений и 23 удалений

Просмотреть файл

@ -28,4 +28,8 @@ export interface Enrollment {
active: boolean;
experimentType: string;
source: string;
// Shown in about:studies
userFacingName: string;
userFacingDescription: string;
lastSeen: string;
}

Просмотреть файл

@ -152,7 +152,13 @@ class _ExperimentManager {
* @memberof _ExperimentManager
*/
async enroll(
{ slug, branches, experimentType = DEFAULT_EXPERIMENT_TYPE },
{
slug,
branches,
experimentType = DEFAULT_EXPERIMENT_TYPE,
userFacingName,
userFacingDescription,
},
source
) {
if (this.store.has(slug)) {
@ -179,6 +185,9 @@ class _ExperimentManager {
enrollmentId,
experimentType,
source,
userFacingName,
userFacingDescription,
lastSeen: new Date().toJSON(),
};
this.store.addExperiment(experiment);
@ -246,7 +255,7 @@ class _ExperimentManager {
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
});
log.debug(`Experiment unenrolled: ${slug}}`);
log.debug(`Experiment unenrolled: ${slug}`);
}
/**

Просмотреть файл

@ -95,6 +95,21 @@ class SharedDataMap extends EventEmitter {
this._notifyUpdate();
}
// Only used in tests
_deleteForTests(key) {
if (!this.isParent) {
throw new Error(
"Setting values from within a content process is not allowed"
);
}
if (this.has(key)) {
delete this._store.data[key];
this._store.saveSoon();
this._syncToChildren();
this._notifyUpdate();
}
}
has(key) {
return Boolean(this._data[key]);
}

Просмотреть файл

@ -63,6 +63,8 @@ const ExperimentFakes = {
{ slug: "control", value: null },
{ slug: "treatment", value: { title: "hello" } },
],
userFacingName: "Messaging System recipe",
userFacingDescription: "Messaging System MSTestUtils recipe",
...props,
};
},

Просмотреть файл

@ -28,6 +28,11 @@ ChromeUtils.defineModuleGetter(
"RecipeRunner",
"resource://normandy/lib/RecipeRunner.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ExperimentManager",
"resource://messaging-system/experiments/ExperimentManager.jsm"
);
var EXPORTED_SYMBOLS = ["AboutPages"];
@ -113,6 +118,10 @@ XPCOMUtils.defineLazyGetter(AboutPages, "aboutStudies", () => {
return PreferenceExperiments.getAll();
},
getMessagingSystemList() {
return ExperimentManager.store.getAll();
},
/** Add a browsing context to the weak set;
* this weak set keeps track of all contexts
* that are housing an about:studies page.
@ -200,6 +209,14 @@ XPCOMUtils.defineLazyGetter(AboutPages, "aboutStudies", () => {
}
},
async removeMessagingSystemExperiment(slug, reason) {
ExperimentManager.unenroll(slug, reason);
this._sendToAll(
"Shield:UpdateMessagingSystemExperimentList",
ExperimentManager.store.getAll()
);
},
openDataPreferences() {
const browserWindow = Services.wm.getMostRecentWindow(
"navigator:browser"

Просмотреть файл

@ -71,6 +71,13 @@ class ShieldFrameChild extends JSWindowActorChild {
prefStudies
);
break;
case "GetRemoteValue:MessagingSystemList":
let experiments = await this.sendQuery("Shield:GetMessagingSystemList");
this.triggerPageCallback(
"ReceiveRemoteValue:MessagingSystemList",
experiments
);
break;
case "RemoveAddonStudy":
this.sendAsyncMessage("Shield:RemoveAddonStudy", event.detail.data);
break;
@ -80,6 +87,12 @@ class ShieldFrameChild extends JSWindowActorChild {
event.detail.data
);
break;
case "RemoveMessagingSystemExperiment":
this.sendAsyncMessage(
"Shield:RemoveMessagingSystemExperiment",
event.detail.data
);
break;
case "GetRemoteValue:StudiesEnabled":
let studiesEnabled = await this.sendQuery("Shield:GetStudiesEnabled");
this.triggerPageCallback(
@ -127,6 +140,12 @@ class ShieldFrameChild extends JSWindowActorChild {
msg.data
);
break;
case "Shield:UpdateMessagingSystemExperimentList":
this.triggerPageCallback(
"ReceiveRemoteValue:MessagingSystemList",
msg.data
);
break;
}
}
/**

Просмотреть файл

@ -25,6 +25,8 @@ class ShieldFrameParent extends JSWindowActorParent {
return aboutStudies.getAddonStudyList();
case "Shield:GetPreferenceStudyList":
return aboutStudies.getPreferenceStudyList();
case "Shield:GetMessagingSystemList":
return aboutStudies.getMessagingSystemList();
case "Shield:RemoveAddonStudy":
aboutStudies.removeAddonStudy(msg.data.recipeId, msg.data.reason);
break;
@ -34,6 +36,12 @@ class ShieldFrameParent extends JSWindowActorParent {
msg.data.reason
);
break;
case "Shield:RemoveMessagingSystemExperiment":
aboutStudies.removeMessagingSystemExperiment(
msg.data.slug,
msg.data.reason
);
break;
case "Shield:OpenDataPreferences":
aboutStudies.openDataPreferences();
break;

Просмотреть файл

@ -33,6 +33,7 @@ class AboutStudies extends React.Component {
this.remoteValueNameMap = {
AddonStudyList: "addonStudies",
PreferenceStudyList: "prefStudies",
MessagingSystemList: "experiments",
ShieldLearnMoreHref: "learnMoreHref",
StudiesEnabled: "studiesEnabled",
ShieldTranslations: "translations",
@ -73,6 +74,7 @@ class AboutStudies extends React.Component {
studiesEnabled,
addonStudies,
prefStudies,
experiments,
} = this.state;
// Wait for all values to be loaded before rendering. Some of the values may
@ -85,7 +87,7 @@ class AboutStudies extends React.Component {
"div",
{ className: "about-studies-container main-content" },
r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }),
r(StudyList, { translations, addonStudies, prefStudies })
r(StudyList, { translations, addonStudies, prefStudies, experiments })
);
}
}
@ -142,9 +144,9 @@ class WhatsThisBox extends React.Component {
*/
class StudyList extends React.Component {
render() {
const { addonStudies, prefStudies, translations } = this.props;
const { addonStudies, prefStudies, translations, experiments } = this.props;
if (!addonStudies.length && !prefStudies.length) {
if (!addonStudies.length && !prefStudies.length && !experiments.length) {
return r("p", { className: "study-list-info" }, translations.noStudies);
}
@ -176,6 +178,18 @@ class StudyList extends React.Component {
}
}
for (const study of experiments) {
const clonedStudy = Object.assign({}, study, {
type: study.experimentType,
sortDate: new Date(study.lastSeen),
});
if (!study.active) {
inactiveStudies.push(clonedStudy);
} else {
activeStudies.push(clonedStudy);
}
}
activeStudies.sort((a, b) => b.sortDate - a.sortDate);
inactiveStudies.sort((a, b) => b.sortDate - a.sortDate);
@ -186,29 +200,53 @@ class StudyList extends React.Component {
r(
"ul",
{ className: "study-list active-study-list" },
activeStudies.map(study =>
study.type === "addon"
? r(AddonStudyListItem, { key: study.slug, study, translations })
: r(PreferenceStudyListItem, {
key: study.slug,
study,
translations,
})
)
activeStudies.map(study => {
if (study.type === "addon") {
return r(AddonStudyListItem, {
key: study.slug,
study,
translations,
});
}
if (study.type === "messaging_experiment") {
return r(MessagingSystemListItem, {
key: study.slug,
study,
translations,
});
}
return r(PreferenceStudyListItem, {
key: study.slug,
study,
translations,
});
})
),
r("h2", {}, translations.completedStudiesList),
r(
"ul",
{ className: "study-list inactive-study-list" },
inactiveStudies.map(study =>
study.type === "addon"
? r(AddonStudyListItem, { key: study.slug, study, translations })
: r(PreferenceStudyListItem, {
key: study.slug,
study,
translations,
})
)
inactiveStudies.map(study => {
if (study.type === "addon") {
return r(AddonStudyListItem, {
key: study.slug,
study,
translations,
});
}
if (study.experimentType === "messaging_experiment") {
return r(MessagingSystemListItem, {
key: study.slug,
study,
translations,
});
}
return r(PreferenceStudyListItem, {
key: study.slug,
study,
translations,
});
})
)
);
}
@ -218,6 +256,66 @@ StudyList.propTypes = {
translations: PropTypes.object.isRequired,
};
class MessagingSystemListItem extends React.Component {
constructor(props) {
super(props);
this.handleClickRemove = this.handleClickRemove.bind(this);
}
handleClickRemove() {
sendPageEvent("RemoveMessagingSystemExperiment", {
slug: this.props.study.slug,
reason: "individual-opt-out",
});
}
render() {
const { study, translations } = this.props;
return r(
"li",
{
className: classnames("study messaging-system", {
disabled: !study.active,
}),
"data-study-slug": study.slug, // used to identify this row in tests
},
r("div", { className: "study-icon" }, study.userFacingName.slice(0, 1)),
r(
"div",
{ className: "study-details" },
r(
"div",
{ className: "study-header" },
r("span", { className: "study-name" }, study.userFacingName),
r("span", {}, "\u2022"), // •
r(
"span",
{ className: "study-status" },
study.active
? translations.activeStatus
: translations.completeStatus
)
),
r(
"div",
{ className: "study-description" },
study.userFacingDescription
)
),
r(
"div",
{ className: "study-actions" },
study.active &&
r(
"button",
{ className: "remove-button", onClick: this.handleClickRemove },
r("div", { className: "button-box" }, translations.removeButton)
)
)
);
}
}
/**
* Details about an individual add-on study, with an option to end it if it is active.
*/

Просмотреть файл

@ -4,6 +4,12 @@ ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/MSTestUtils.jsm"
);
const { ExperimentManager } = ChromeUtils.import(
"resource://messaging-system/experiments/ExperimentManager.jsm"
);
const { NormandyTestUtils } = ChromeUtils.import(
"resource://testing-common/NormandyTestUtils.jsm"
@ -620,3 +626,49 @@ decorate_task(
);
}
);
add_task(async function test_messaging_system_about_studies() {
const recipe = ExperimentFakes.recipe("about-studies-foo");
await ExperimentManager.enroll(recipe);
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:studies" },
async browser => {
const name = await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(".messaging-system .remove-button"),
"waiting for page/experiment to load"
);
return content.document.querySelector(".study-name").innerText;
});
// Make sure strings are properly shown
Assert.equal(
name,
recipe.userFacingName,
"Correct active experiment name"
);
}
);
ExperimentManager.unenroll(recipe.slug);
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:studies" },
async browser => {
const name = await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".messaging-system.disabled"),
"waiting for experiment to become disabled"
);
return content.document.querySelector(".study-name").innerText;
});
// Make sure strings are properly shown
Assert.equal(
name,
recipe.userFacingName,
"Correct disabled experiment name"
);
}
);
// Cleanup for multiple test runs
ExperimentManager.store._deleteForTests(recipe.slug);
Assert.equal(ExperimentManager.store.getAll().length, 0, "Cleanup done");
});