зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1447499 - Add preference studies to about:studies r=Gijs
Differential Revision: https://phabricator.services.mozilla.com/D5481 --HG-- extra : moz-landing-system : lando
This commit is contained in:
Родитель
d15f0d0fa0
Коммит
3c8bd4ab57
|
@ -6,9 +6,10 @@
|
|||
ChromeUtils.import("resource://gre/modules/Services.jsm");
|
||||
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
ChromeUtils.defineModuleGetter(this, "AddonStudies", "resource://normandy/lib/AddonStudies.jsm");
|
||||
ChromeUtils.defineModuleGetter(this, "AddonStudyAction", "resource://normandy/actions/AddonStudyAction.jsm");
|
||||
ChromeUtils.defineModuleGetter(this, "CleanupManager", "resource://normandy/lib/CleanupManager.jsm");
|
||||
ChromeUtils.defineModuleGetter(this, "AddonStudies", "resource://normandy/lib/AddonStudies.jsm");
|
||||
ChromeUtils.defineModuleGetter(this, "PreferenceExperiments", "resource://normandy/lib/PreferenceExperiments.jsm");
|
||||
ChromeUtils.defineModuleGetter(this, "RecipeRunner", "resource://normandy/lib/RecipeRunner.jsm");
|
||||
|
||||
var EXPORTED_SYMBOLS = ["AboutPages"];
|
||||
|
@ -95,8 +96,10 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
|||
* Register listeners for messages from the content processes.
|
||||
*/
|
||||
registerParentListeners() {
|
||||
Services.mm.addMessageListener("Shield:GetStudyList", this);
|
||||
Services.mm.addMessageListener("Shield:RemoveStudy", this);
|
||||
Services.mm.addMessageListener("Shield:GetAddonStudyList", this);
|
||||
Services.mm.addMessageListener("Shield:GetPreferenceStudyList", this);
|
||||
Services.mm.addMessageListener("Shield:RemoveAddonStudy", this);
|
||||
Services.mm.addMessageListener("Shield:RemovePreferenceStudy", this);
|
||||
Services.mm.addMessageListener("Shield:OpenDataPreferences", this);
|
||||
Services.mm.addMessageListener("Shield:GetStudiesEnabled", this);
|
||||
},
|
||||
|
@ -105,8 +108,10 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
|||
* Unregister listeners for messages from the content process.
|
||||
*/
|
||||
unregisterParentListeners() {
|
||||
Services.mm.removeMessageListener("Shield:GetStudyList", this);
|
||||
Services.mm.removeMessageListener("Shield:RemoveStudy", this);
|
||||
Services.mm.removeMessageListener("Shield:GetAddonStudyList", this);
|
||||
Services.mm.removeMessageListener("Shield:GetPreferenceStudyList", this);
|
||||
Services.mm.removeMessageListener("Shield:RemoveAddonStudy", this);
|
||||
Services.mm.removeMessageListener("Shield:RemovePreferenceStudy", this);
|
||||
Services.mm.removeMessageListener("Shield:OpenDataPreferences", this);
|
||||
Services.mm.removeMessageListener("Shield:GetStudiesEnabled", this);
|
||||
},
|
||||
|
@ -118,11 +123,17 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
|||
*/
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "Shield:GetStudyList":
|
||||
this.sendStudyList(message.target);
|
||||
case "Shield:GetAddonStudyList":
|
||||
this.sendAddonStudyList(message.target);
|
||||
break;
|
||||
case "Shield:RemoveStudy":
|
||||
this.removeStudy(message.data.recipeId, message.data.reason);
|
||||
case "Shield:GetPreferenceStudyList":
|
||||
this.sendPreferenceStudyList(message.target);
|
||||
break;
|
||||
case "Shield:RemoveAddonStudy":
|
||||
this.removeAddonStudy(message.data.recipeId, message.data.reason);
|
||||
break;
|
||||
case "Shield:RemovePreferenceStudy":
|
||||
this.removePreferenceStudy(message.data.experimentName, message.data.reason);
|
||||
break;
|
||||
case "Shield:OpenDataPreferences":
|
||||
this.openDataPreferences();
|
||||
|
@ -134,15 +145,15 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch a list of studies from storage and send it to the process that
|
||||
* requested them.
|
||||
* Fetch a list of add-on studies from storage and send it to the process
|
||||
* that requested them.
|
||||
* @param {<browser>} target
|
||||
* XUL <browser> element for the tab containing the about:studies page
|
||||
* that requested a study list.
|
||||
*/
|
||||
async sendStudyList(target) {
|
||||
async sendAddonStudyList(target) {
|
||||
try {
|
||||
target.messageManager.sendAsyncMessage("Shield:ReceiveStudyList", {
|
||||
target.messageManager.sendAsyncMessage("Shield:ReceiveAddonStudyList", {
|
||||
studies: await AddonStudies.getAll(),
|
||||
});
|
||||
} catch (err) {
|
||||
|
@ -151,6 +162,24 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a list of preference studies from storage and send it to the
|
||||
* process that requested them.
|
||||
* @param {<browser>} target
|
||||
* XUL <browser> element for the tab containing the about:studies page
|
||||
* that requested a study list.
|
||||
*/
|
||||
async sendPreferenceStudyList(target) {
|
||||
try {
|
||||
target.messageManager.sendAsyncMessage("Shield:ReceivePreferenceStudyList", {
|
||||
studies: await PreferenceExperiments.getAll(),
|
||||
});
|
||||
} catch (err) {
|
||||
// The child process might be gone, so no need to throw here.
|
||||
Cu.reportError(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get if studies are enabled and send it to the process that
|
||||
* requested them. This has to be in the parent process, since
|
||||
|
@ -174,19 +203,32 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
|||
},
|
||||
|
||||
/**
|
||||
* Disable an active study and remove its add-on.
|
||||
* Disable an active add-on study and remove its add-on.
|
||||
* @param {String} studyName
|
||||
*/
|
||||
async removeStudy(recipeId, reason) {
|
||||
async removeAddonStudy(recipeId, reason) {
|
||||
const action = new AddonStudyAction();
|
||||
await action.unenroll(recipeId, reason);
|
||||
|
||||
// Update any open tabs with the new study list now that it has changed.
|
||||
Services.mm.broadcastAsyncMessage("Shield:ReceiveStudyList", {
|
||||
Services.mm.broadcastAsyncMessage("Shield:ReceiveAddonStudyList", {
|
||||
studies: await AddonStudies.getAll(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable an active preference study
|
||||
* @param {String} studyName
|
||||
*/
|
||||
async removePreferenceStudy(experimentName, reason) {
|
||||
PreferenceExperiments.stop(experimentName, { reason });
|
||||
|
||||
// Update any open tabs with the new study list now that it has changed.
|
||||
Services.mm.broadcastAsyncMessage("Shield:ReceivePreferenceStudyList", {
|
||||
studies: await PreferenceExperiments.getAll(),
|
||||
});
|
||||
},
|
||||
|
||||
openDataPreferences() {
|
||||
const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
|
||||
browserWindow.openPreferences("privacy-reports", {origin: "aboutStudies"});
|
||||
|
|
|
@ -42,16 +42,23 @@ class ShieldFrameChild extends ActorChild {
|
|||
// We waited until after we received an event to register message listeners
|
||||
// in order to save resources for tabs that don't ever load about:studies.
|
||||
this.mm.addMessageListener("Shield:ShuttingDown", this);
|
||||
this.mm.addMessageListener("Shield:ReceiveStudyList", this);
|
||||
this.mm.addMessageListener("Shield:ReceiveAddonStudyList", this);
|
||||
this.mm.addMessageListener("Shield:ReceivePreferenceStudyList", this);
|
||||
this.mm.addMessageListener("Shield:ReceiveStudiesEnabled", this);
|
||||
|
||||
switch (event.detail.action) {
|
||||
// Actions that require the parent process
|
||||
case "GetRemoteValue:StudyList":
|
||||
this.mm.sendAsyncMessage("Shield:GetStudyList");
|
||||
case "GetRemoteValue:AddonStudyList":
|
||||
this.mm.sendAsyncMessage("Shield:GetAddonStudyList");
|
||||
break;
|
||||
case "RemoveStudy":
|
||||
this.mm.sendAsyncMessage("Shield:RemoveStudy", event.detail.data);
|
||||
case "GetRemoteValue:PreferenceStudyList":
|
||||
this.mm.sendAsyncMessage("Shield:GetPreferenceStudyList");
|
||||
break;
|
||||
case "RemoveAddonStudy":
|
||||
this.mm.sendAsyncMessage("Shield:RemoveAddonStudy", event.detail.data);
|
||||
break;
|
||||
case "RemovePreferenceStudy":
|
||||
this.mm.sendAsyncMessage("Shield:RemovePreferenceStudy", event.detail.data);
|
||||
break;
|
||||
case "GetRemoteValue:StudiesEnabled":
|
||||
this.mm.sendAsyncMessage("Shield:GetStudiesEnabled");
|
||||
|
@ -89,8 +96,11 @@ class ShieldFrameChild extends ActorChild {
|
|||
*/
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "Shield:ReceiveStudyList":
|
||||
this.triggerPageCallback("ReceiveRemoteValue:StudyList", message.data.studies);
|
||||
case "Shield:ReceiveAddonStudyList":
|
||||
this.triggerPageCallback("ReceiveRemoteValue:AddonStudyList", message.data.studies);
|
||||
break;
|
||||
case "Shield:ReceivePreferenceStudyList":
|
||||
this.triggerPageCallback("ReceiveRemoteValue:PreferenceStudyList", message.data.studies);
|
||||
break;
|
||||
case "Shield:ReceiveStudiesEnabled":
|
||||
this.triggerPageCallback("ReceiveRemoteValue:StudiesEnabled", message.data.studiesEnabled);
|
||||
|
|
|
@ -31,25 +31,10 @@ button > .button-box {
|
|||
|
||||
.about-studies-container {
|
||||
font-size: 1.25rem;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#categories {
|
||||
flex: 0 0;
|
||||
margin: 0;
|
||||
min-width: 200px;
|
||||
padding: 40px 0 0;
|
||||
}
|
||||
|
||||
#categories .category {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
|
@ -156,23 +141,21 @@ button > .button-box {
|
|||
.study-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3em;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.study-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.study-description > * {
|
||||
.study-header > * {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.study-description > *:last-child {
|
||||
.study-header > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.study-description code {
|
||||
font: italic 1.0rem 'Fira Mono', 'mono', 'monospace';
|
||||
}
|
||||
|
||||
.study-actions {
|
||||
flex: 0 0;
|
||||
}
|
||||
|
|
|
@ -28,7 +28,8 @@ class AboutStudies extends React.Component {
|
|||
super(props);
|
||||
|
||||
this.remoteValueNameMap = {
|
||||
StudyList: "addonStudies",
|
||||
AddonStudyList: "addonStudies",
|
||||
PreferenceStudyList: "prefStudies",
|
||||
ShieldLearnMoreHref: "learnMoreHref",
|
||||
StudiesEnabled: "studiesEnabled",
|
||||
ShieldTranslations: "translations",
|
||||
|
@ -63,7 +64,7 @@ class AboutStudies extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { translations, learnMoreHref, studiesEnabled, addonStudies } = this.state;
|
||||
const { translations, learnMoreHref, studiesEnabled, addonStudies, prefStudies } = this.state;
|
||||
|
||||
// Wait for all values to be loaded before rendering. Some of the values may
|
||||
// be falsey, so an explicit null check is needed.
|
||||
|
@ -74,7 +75,7 @@ class AboutStudies extends React.Component {
|
|||
return (
|
||||
r("div", { className: "about-studies-container main-content" },
|
||||
r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }),
|
||||
r(StudyList, { translations, addonStudies }),
|
||||
r(StudyList, { translations, addonStudies, prefStudies }),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -115,24 +116,55 @@ class WhatsThisBox extends React.Component {
|
|||
*/
|
||||
class StudyList extends React.Component {
|
||||
render() {
|
||||
const { addonStudies, translations } = this.props;
|
||||
const { addonStudies, prefStudies, translations } = this.props;
|
||||
|
||||
if (!addonStudies.length) {
|
||||
if (!addonStudies.length && !prefStudies.length) {
|
||||
return r("p", { className: "study-list-info" }, translations.noStudies);
|
||||
}
|
||||
|
||||
addonStudies.sort((a, b) => {
|
||||
if (a.active !== b.active) {
|
||||
return a.active ? -1 : 1;
|
||||
const activeStudies = [];
|
||||
const inactiveStudies = [];
|
||||
|
||||
// Since we are modifying the study objects, it is polite to make copies
|
||||
for (const study of addonStudies) {
|
||||
const clonedStudy = Object.assign({}, study, {type: "addon", sortDate: study.studyStartDate});
|
||||
if (study.active) {
|
||||
activeStudies.push(clonedStudy);
|
||||
} else {
|
||||
inactiveStudies.push(clonedStudy);
|
||||
}
|
||||
return b.studyStartDate - a.studyStartDate;
|
||||
});
|
||||
}
|
||||
|
||||
for (const study of prefStudies) {
|
||||
const clonedStudy = Object.assign({}, study, {type: "pref", sortDate: new Date(study.lastSeen)});
|
||||
if (study.expired) {
|
||||
inactiveStudies.push(clonedStudy);
|
||||
} else {
|
||||
activeStudies.push(clonedStudy);
|
||||
}
|
||||
}
|
||||
|
||||
activeStudies.sort((a, b) => b.sortDate - a.sortDate);
|
||||
inactiveStudies.sort((a, b) => b.sortDate - a.sortDate);
|
||||
|
||||
return (
|
||||
r("ul", { className: "study-list" },
|
||||
addonStudies.map(study => (
|
||||
r(StudyListItem, { key: study.name, study, translations })
|
||||
))
|
||||
r("div", {},
|
||||
r("h2", {}, translations.activeStudiesList),
|
||||
r("ul", { className: "study-list" },
|
||||
activeStudies.map(study => (
|
||||
study.type === "addon"
|
||||
? r(AddonStudyListItem, { key: study.name, study, translations })
|
||||
: r(PreferenceStudyListItem, { key: study.name, study, translations })
|
||||
)),
|
||||
),
|
||||
r("h2", {}, translations.completedStudiesList),
|
||||
r("ul", { className: "study-list" },
|
||||
inactiveStudies.map(study => (
|
||||
study.type === "addon"
|
||||
? r(AddonStudyListItem, { key: study.name, study, translations })
|
||||
: r(PreferenceStudyListItem, { key: study.name, study, translations })
|
||||
)),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -143,34 +175,39 @@ StudyList.propTypes = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Details about an individual study, with an option to end it if it is active.
|
||||
* Details about an individual add-on study, with an option to end it if it is active.
|
||||
*/
|
||||
class StudyListItem extends React.Component {
|
||||
class AddonStudyListItem extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickRemove = this.handleClickRemove.bind(this);
|
||||
}
|
||||
|
||||
handleClickRemove() {
|
||||
sendPageEvent("RemoveStudy", { recipeId: this.props.study.recipeId, reason: "individual-opt-out" });
|
||||
sendPageEvent("RemoveAddonStudy", {
|
||||
recipeId: this.props.study.recipeId,
|
||||
reason: "individual-opt-out",
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { study, translations } = this.props;
|
||||
return (
|
||||
r("li", {
|
||||
className: classnames("study", { disabled: !study.active }),
|
||||
className: classnames("study addon-study", { disabled: !study.active }),
|
||||
"data-study-name": study.name,
|
||||
},
|
||||
r("div", { className: "study-icon" },
|
||||
study.name.slice(0, 1)
|
||||
study.name.replace(/-?add-?on-?/, "").replace(/-?study-?/, "").slice(0, 1)
|
||||
),
|
||||
r("div", { className: "study-details" },
|
||||
r("div", { className: "study-name" }, study.name),
|
||||
r("div", { className: "study-description", title: study.description },
|
||||
r("span", { className: "study-status" }, study.active ? translations.activeStatus : translations.completeStatus),
|
||||
r("div", { className: "study-header" },
|
||||
r("span", { className: "study-name" }, study.name),
|
||||
r("span", {}, "\u2022"), // •
|
||||
r("span", {}, study.description),
|
||||
r("span", { className: "study-status" }, study.active ? translations.activeStatus : translations.completeStatus),
|
||||
),
|
||||
r("div", { className: "study-description" },
|
||||
study.description
|
||||
),
|
||||
),
|
||||
r("div", { className: "study-actions" },
|
||||
|
@ -185,14 +222,84 @@ class StudyListItem extends React.Component {
|
|||
);
|
||||
}
|
||||
}
|
||||
StudyListItem.propTypes = {
|
||||
AddonStudyListItem.propTypes = {
|
||||
study: PropTypes.shape({
|
||||
recipeId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
active: PropTypes.boolean,
|
||||
active: PropTypes.bool.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
translations: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Details about an individual preference study, with an option to end it if it is active.
|
||||
*/
|
||||
class PreferenceStudyListItem extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickRemove = this.handleClickRemove.bind(this);
|
||||
}
|
||||
|
||||
handleClickRemove() {
|
||||
sendPageEvent("RemovePreferenceStudy", {
|
||||
experimentName: this.props.study.name,
|
||||
reason: "individual-opt-out",
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { study, translations } = this.props;
|
||||
|
||||
// Sanitize the values by setting them as the text content of an element,
|
||||
// and then getting the HTML representation of that text. This will have the
|
||||
// browser safely sanitize them. Use outerHTML to also include the <code>
|
||||
// element in the string.
|
||||
const sanitizer = document.createElement("code");
|
||||
sanitizer.textContent = study.preferenceName;
|
||||
const sanitizedPreferenceName = sanitizer.outerHTML;
|
||||
sanitizer.textContent = study.preferenceValue;
|
||||
const sanitizedPreferenceValue = sanitizer.outerHTML;
|
||||
const description = translations.preferenceStudyDescription
|
||||
.replace(/%(?:1\$)?S/, sanitizedPreferenceName)
|
||||
.replace(/%(?:2\$)?S/, sanitizedPreferenceValue);
|
||||
|
||||
return (
|
||||
r("li", {
|
||||
className: classnames("study pref-study", { disabled: study.expired }),
|
||||
"data-study-name": study.name,
|
||||
},
|
||||
r("div", { className: "study-icon" },
|
||||
study.name.replace(/-?pref-?(flip|study)-?/, "").replace(/-?study-?/, "").slice(0, 1)
|
||||
),
|
||||
r("div", { className: "study-details" },
|
||||
r("div", { className: "study-header" },
|
||||
r("span", { className: "study-name" }, study.name),
|
||||
r("span", {}, "\u2022"), // •
|
||||
r("span", { className: "study-status" }, study.expired ? translations.completeStatus : translations.activeStatus),
|
||||
),
|
||||
r("div", { className: "study-description", dangerouslySetInnerHTML: { __html: description }}),
|
||||
),
|
||||
r("div", { className: "study-actions" },
|
||||
!study.expired &&
|
||||
r("button", { className: "remove-button", onClick: this.handleClickRemove },
|
||||
r("div", { className: "button-box" },
|
||||
translations.removeButton
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
PreferenceStudyListItem.propTypes = {
|
||||
study: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
expired: PropTypes.bool.isRequired,
|
||||
preferenceName: PropTypes.string.isRequired,
|
||||
preferenceValue: PropTypes.oneOf(PropTypes.string, PropTypes.bool, PropTypes.number).isRequired,
|
||||
}).isRequired,
|
||||
translations: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
ReactDOM.render(r(AboutStudies), document.getElementById("app"));
|
||||
|
|
|
@ -33,7 +33,7 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({name: "test-study"}),
|
||||
addonStudyFactory({name: "test-study"}),
|
||||
]),
|
||||
async function testGet([study]) {
|
||||
const storedStudy = await AddonStudies.get(study.recipeId);
|
||||
|
@ -43,8 +43,8 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory(),
|
||||
studyFactory(),
|
||||
addonStudyFactory(),
|
||||
addonStudyFactory(),
|
||||
]),
|
||||
async function testGetAll(studies) {
|
||||
const storedStudies = await AddonStudies.getAll();
|
||||
|
@ -58,7 +58,7 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({name: "test-study"}),
|
||||
addonStudyFactory({name: "test-study"}),
|
||||
]),
|
||||
async function testHas([study]) {
|
||||
let hasStudy = await AddonStudies.has(study.recipeId);
|
||||
|
@ -95,8 +95,8 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({name: "test-study1"}),
|
||||
studyFactory({name: "test-study2"}),
|
||||
addonStudyFactory({name: "test-study1"}),
|
||||
addonStudyFactory({name: "test-study2"}),
|
||||
]),
|
||||
async function testClear([study1, study2]) {
|
||||
const hasAll = (
|
||||
|
@ -116,9 +116,9 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: "does.not.exist@example.com", studyEndDate: null}),
|
||||
studyFactory({active: true, addonId: "installed@example.com"}),
|
||||
studyFactory({active: false, addonId: "already.gone@example.com", studyEndDate: new Date(2012, 1)}),
|
||||
addonStudyFactory({active: true, addonId: "does.not.exist@example.com", studyEndDate: null}),
|
||||
addonStudyFactory({active: true, addonId: "installed@example.com"}),
|
||||
addonStudyFactory({active: false, addonId: "already.gone@example.com", studyEndDate: new Date(2012, 1)}),
|
||||
]),
|
||||
withSendEventStub,
|
||||
withInstalledWebExtension({id: "installed@example.com"}),
|
||||
|
@ -162,7 +162,7 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
|
||||
addonStudyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
|
||||
]),
|
||||
withInstalledWebExtension({id: "installed@example.com"}, /* expectUninstall: */ true),
|
||||
async function testInit([study], [id, addonFile]) {
|
||||
|
|
|
@ -11,8 +11,8 @@ ShieldPreferences.init();
|
|||
decorate_task(
|
||||
withMockPreferences,
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true}),
|
||||
studyFactory({active: true}),
|
||||
addonStudyFactory({active: true}),
|
||||
addonStudyFactory({active: true}),
|
||||
]),
|
||||
async function testDisableStudiesWhenOptOutDisabled(mockPreferences, [study1, study2]) {
|
||||
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
"use strict";
|
||||
|
||||
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);
|
||||
|
||||
function withAboutStudies(testFunc) {
|
||||
return async (...args) => (
|
||||
return async (...args) => (
|
||||
BrowserTestUtils.withNewTab("about:studies", async browser => (
|
||||
testFunc(...args, browser)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
// Test that the code renders at all
|
||||
decorate_task(
|
||||
withAboutStudies,
|
||||
async function testAboutStudiesWorks(browser) {
|
||||
|
@ -20,6 +22,7 @@ decorate_task(
|
|||
}
|
||||
);
|
||||
|
||||
// Test that the learn more element is displayed correctly
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["app.normandy.shieldLearnMoreUrl", "http://test/%OS%/"]],
|
||||
|
@ -41,9 +44,10 @@ decorate_task(
|
|||
}
|
||||
);
|
||||
|
||||
// Test that jumping to preferences worked as expected
|
||||
decorate_task(
|
||||
withAboutStudies,
|
||||
async function testUpdatePreferencesNewOrganization(browser) {
|
||||
async function testUpdatePreferences(browser) {
|
||||
let loadPromise = BrowserTestUtils.firstBrowserLoaded(window);
|
||||
|
||||
// We have to use gBrowser instead of browser in most spots since we're
|
||||
|
@ -69,90 +73,159 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
// Sort order should be study3, study1, study2 (order by enabled, then most recent).
|
||||
studyFactory({
|
||||
name: "A Fake Study",
|
||||
addonStudyFactory({
|
||||
name: "A Fake Add-on Study",
|
||||
active: true,
|
||||
description: "A fake description",
|
||||
studyStartDate: new Date(2017),
|
||||
studyStartDate: new Date(2018, 0, 4),
|
||||
}),
|
||||
studyFactory({
|
||||
name: "B Fake Study",
|
||||
addonStudyFactory({
|
||||
name: "B Fake Add-on Study",
|
||||
active: false,
|
||||
description: "A fake description",
|
||||
studyStartDate: new Date(2019),
|
||||
description: "B fake description",
|
||||
studyStartDate: new Date(2018, 0, 2),
|
||||
}),
|
||||
studyFactory({
|
||||
name: "C Fake Study",
|
||||
addonStudyFactory({
|
||||
name: "C Fake Add-on Study",
|
||||
active: true,
|
||||
description: "A fake description",
|
||||
studyStartDate: new Date(2018),
|
||||
description: "C fake description",
|
||||
studyStartDate: new Date(2018, 0, 1),
|
||||
}),
|
||||
]),
|
||||
PreferenceExperiments.withMockExperiments([
|
||||
preferenceStudyFactory({
|
||||
name: "D Fake Preference Study",
|
||||
lastSeen: new Date(2018, 0, 3),
|
||||
expired: false,
|
||||
}),
|
||||
preferenceStudyFactory({
|
||||
name: "E Fake Preference Study",
|
||||
lastSeen: new Date(2018, 0, 5),
|
||||
expired: true,
|
||||
}),
|
||||
preferenceStudyFactory({
|
||||
name: "F Fake Preference Study",
|
||||
lastSeen: new Date(2018, 0, 6),
|
||||
expired: false,
|
||||
}),
|
||||
]),
|
||||
withAboutStudies,
|
||||
async function testStudyListing([study1, study2, study3], browser) {
|
||||
await ContentTask.spawn(browser, [study1, study2, study3], async ([cStudy1, cStudy2, cStudy3]) => {
|
||||
async function testStudyListing(addonStudies, prefStudies, browser) {
|
||||
await ContentTask.spawn(browser, { addonStudies, prefStudies }, async ({ addonStudies, prefStudies }) => {
|
||||
const doc = content.document;
|
||||
|
||||
function getStudyRow(docElem, studyName) {
|
||||
return docElem.querySelector(`.study[data-study-name="${studyName}"]`);
|
||||
}
|
||||
|
||||
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list .study").length);
|
||||
const studyRows = doc.querySelectorAll(".study-list .study");
|
||||
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".active-study-list .study").length);
|
||||
const activeNames = Array.from(doc.querySelectorAll(".active-study-list .study"))
|
||||
.map(row => row.dataset.studyName);
|
||||
const inactiveNames = Array.from(doc.querySelectorAll(".inactive-study-list .study"))
|
||||
.map(row => row.dataset.studyName);
|
||||
|
||||
const names = Array.from(studyRows).map(row => row.querySelector(".study-name").textContent);
|
||||
Assert.deepEqual(
|
||||
names,
|
||||
[cStudy3.name, cStudy1.name, cStudy2.name],
|
||||
"Studies are sorted first by enabled status, and then by descending start date."
|
||||
activeNames,
|
||||
[prefStudies[2].name, addonStudies[0].name, prefStudies[0].name, addonStudies[2].name],
|
||||
"Active studies are grouped by enabled status, and sorted by date",
|
||||
);
|
||||
Assert.deepEqual(
|
||||
inactiveNames,
|
||||
[prefStudies[1].name, addonStudies[1].name],
|
||||
"Inactive studies are grouped by enabled status, and sorted by date",
|
||||
);
|
||||
|
||||
const study1Row = getStudyRow(doc, cStudy1.name);
|
||||
const activeAddonStudy = getStudyRow(doc, addonStudies[0].name);
|
||||
ok(
|
||||
study1Row.querySelector(".study-description").textContent.includes(cStudy1.description),
|
||||
activeAddonStudy.querySelector(".study-description").textContent.includes(addonStudies[0].description),
|
||||
"Study descriptions are shown in about:studies."
|
||||
);
|
||||
is(
|
||||
study1Row.querySelector(".study-status").textContent,
|
||||
activeAddonStudy.querySelector(".study-status").textContent,
|
||||
"Active",
|
||||
"Active studies show an 'Active' indicator."
|
||||
);
|
||||
ok(
|
||||
study1Row.querySelector(".remove-button"),
|
||||
activeAddonStudy.querySelector(".remove-button"),
|
||||
"Active studies show a remove button"
|
||||
);
|
||||
is(
|
||||
study1Row.querySelector(".study-icon").textContent.toLowerCase(),
|
||||
activeAddonStudy.querySelector(".study-icon").textContent.toLowerCase(),
|
||||
"a",
|
||||
"Study icons use the first letter of the study name."
|
||||
);
|
||||
|
||||
const study2Row = getStudyRow(doc, cStudy2.name);
|
||||
const inactiveAddonStudy = getStudyRow(doc, addonStudies[1].name);
|
||||
is(
|
||||
study2Row.querySelector(".study-status").textContent,
|
||||
inactiveAddonStudy.querySelector(".study-status").textContent,
|
||||
"Complete",
|
||||
"Inactive studies are marked as complete."
|
||||
);
|
||||
ok(
|
||||
!study2Row.querySelector(".remove-button"),
|
||||
!inactiveAddonStudy.querySelector(".remove-button"),
|
||||
"Inactive studies do not show a remove button"
|
||||
);
|
||||
|
||||
study1Row.querySelector(".remove-button").click();
|
||||
const activePrefStudy = getStudyRow(doc, prefStudies[0].name);
|
||||
ok(
|
||||
activePrefStudy.querySelector(".study-description").textContent.includes(prefStudies[0].preferenceName),
|
||||
"Preference studies show the preference they are changing"
|
||||
);
|
||||
is(
|
||||
activePrefStudy.querySelector(".study-status").textContent,
|
||||
"Active",
|
||||
"Active studies show an 'Active' indicator."
|
||||
);
|
||||
ok(
|
||||
activePrefStudy.querySelector(".remove-button"),
|
||||
"Active studies show a remove button"
|
||||
);
|
||||
is(
|
||||
activePrefStudy.querySelector(".study-icon").textContent.toLowerCase(),
|
||||
"d",
|
||||
"Study icons use the first letter of the study name."
|
||||
);
|
||||
|
||||
const inactivePrefStudy = getStudyRow(doc, prefStudies[1].name);
|
||||
is(
|
||||
inactivePrefStudy.querySelector(".study-status").textContent,
|
||||
"Complete",
|
||||
"Inactive studies are marked as complete."
|
||||
);
|
||||
ok(
|
||||
!inactivePrefStudy.querySelector(".remove-button"),
|
||||
"Inactive studies do not show a remove button"
|
||||
);
|
||||
|
||||
activeAddonStudy.querySelector(".remove-button").click();
|
||||
await ContentTaskUtils.waitForCondition(() => (
|
||||
getStudyRow(doc, cStudy1.name).matches(".disabled")
|
||||
getStudyRow(doc, addonStudies[0].name).matches(".study--disabled")
|
||||
));
|
||||
ok(
|
||||
getStudyRow(doc, cStudy1.name).matches(".disabled"),
|
||||
getStudyRow(doc, addonStudies[0].name).matches(".study--disabled"),
|
||||
"Clicking the remove button updates the UI to show that the study has been disabled."
|
||||
);
|
||||
|
||||
activePrefStudy.querySelector(".remove-button").click();
|
||||
await ContentTaskUtils.waitForCondition(() => (
|
||||
getStudyRow(doc, prefStudies[0].name).matches(".study--disabled")
|
||||
));
|
||||
ok(
|
||||
getStudyRow(doc, prefStudies[0].name).matches(".study--disabled"),
|
||||
"Clicking the remove button updates the UI to show that the study has been disabled."
|
||||
);
|
||||
});
|
||||
|
||||
const updatedStudy1 = await AddonStudies.get(study1.recipeId);
|
||||
const updatedAddonStudy = await AddonStudies.get(addonStudies[0].recipeId);
|
||||
ok(
|
||||
!updatedStudy1.active,
|
||||
"Clicking the remove button marks the study as inactive in storage."
|
||||
!updatedAddonStudy.active,
|
||||
"Clicking the remove button marks addon studies as inactive in storage."
|
||||
);
|
||||
|
||||
const updatedPrefStudy = await PreferenceExperiments.get(prefStudies[0].name);
|
||||
ok(
|
||||
updatedPrefStudy.expired,
|
||||
"Clicking the remove button marks preference studies as expired in storage."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -163,7 +236,7 @@ decorate_task(
|
|||
async function testStudyListing(studies, browser) {
|
||||
await ContentTask.spawn(browser, null, async () => {
|
||||
const doc = content.document;
|
||||
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list").length);
|
||||
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list-info").length);
|
||||
const studyRows = doc.querySelectorAll(".study-list .study");
|
||||
is(studyRows.length, 0, "There should be no studies");
|
||||
is(
|
||||
|
|
|
@ -41,7 +41,7 @@ function ensureAddonCleanup(testFunction) {
|
|||
// Test that enroll is not called if recipe is already enrolled
|
||||
decorate_task(
|
||||
ensureAddonCleanup,
|
||||
AddonStudies.withStudies([studyFactory()]),
|
||||
AddonStudies.withStudies([addonStudyFactory()]),
|
||||
withSendEventStub,
|
||||
async function enrollTwiceFail([study], sendEventStub) {
|
||||
const recipe = recipeFactory({
|
||||
|
@ -176,7 +176,7 @@ decorate_task(
|
|||
decorate_task(
|
||||
ensureAddonCleanup,
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: false}),
|
||||
addonStudyFactory({active: false}),
|
||||
]),
|
||||
withSendEventStub,
|
||||
async ([study], sendEventStub) => {
|
||||
|
@ -194,7 +194,7 @@ const testStopId = "testStop@example.com";
|
|||
decorate_task(
|
||||
ensureAddonCleanup,
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: testStopId, studyEndDate: null}),
|
||||
addonStudyFactory({active: true, addonId: testStopId, studyEndDate: null}),
|
||||
]),
|
||||
withInstalledWebExtension({id: testStopId}, /* expectUninstall: */ true),
|
||||
withSendEventStub,
|
||||
|
@ -228,7 +228,7 @@ decorate_task(
|
|||
decorate_task(
|
||||
ensureAddonCleanup,
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: "missingAddon@example.com", studyEndDate: null}),
|
||||
addonStudyFactory({active: true, addonId: "missingAddon@example.com", studyEndDate: null}),
|
||||
]),
|
||||
withSendEventStub,
|
||||
async function unenrollTest([study], sendEventStub) {
|
||||
|
@ -291,7 +291,7 @@ decorate_task(
|
|||
// Test that enroll is not called if recipe is already enrolled
|
||||
decorate_task(
|
||||
ensureAddonCleanup,
|
||||
AddonStudies.withStudies([studyFactory()]),
|
||||
AddonStudies.withStudies([addonStudyFactory()]),
|
||||
async function enrollTwiceFail([study]) {
|
||||
const action = new AddonStudyAction();
|
||||
const unenrollSpy = sinon.stub(action, "unenroll");
|
||||
|
|
|
@ -280,10 +280,10 @@ this.decorate_task = function(...args) {
|
|||
return add_task(decorate(...args));
|
||||
};
|
||||
|
||||
let _studyFactoryId = 0;
|
||||
this.studyFactory = function(attrs) {
|
||||
let _addonStudyFactoryId = 0;
|
||||
this.addonStudyFactory = function(attrs) {
|
||||
return Object.assign({
|
||||
recipeId: _studyFactoryId++,
|
||||
recipeId: _addonStudyFactoryId++,
|
||||
name: "Test study",
|
||||
description: "fake",
|
||||
active: true,
|
||||
|
@ -294,6 +294,22 @@ this.studyFactory = function(attrs) {
|
|||
}, attrs);
|
||||
};
|
||||
|
||||
let _preferenceStudyFactoryId = 0;
|
||||
this.preferenceStudyFactory = function(attrs) {
|
||||
return Object.assign({
|
||||
name: "Test study",
|
||||
branch: "control",
|
||||
expired: false,
|
||||
lastSeen: new Date().toJSON(),
|
||||
preferenceName: "test.study",
|
||||
preferenceValue: false,
|
||||
preferenceType: "boolean",
|
||||
previousPreferenceValue: undefined,
|
||||
preferenceBranchType: "default",
|
||||
experimentType: "exp",
|
||||
}, attrs);
|
||||
};
|
||||
|
||||
this.withStub = function(...stubArgs) {
|
||||
return function wrapper(testFunction) {
|
||||
return async function wrappedTestFunction(...args) {
|
||||
|
|
|
@ -8,9 +8,12 @@
|
|||
title = Shield Studies
|
||||
removeButton = Remove
|
||||
|
||||
# LOCALIZATION NOTE (activeStudiesList): Title above a list of active studies
|
||||
activeStudiesList = Active studies
|
||||
# LOCALIZATION NOTE (activeStudiesList): Title above a list of completed studies
|
||||
completedStudiesList = Completed studies
|
||||
# LOCALIZATION NOTE (activeStatus): Displayed for an active study
|
||||
activeStatus = Active
|
||||
|
||||
# LOCALIZATION NOTE (completeStatus): Displayed for a study that is already complete
|
||||
completeStatus = Complete
|
||||
|
||||
|
@ -21,3 +24,9 @@ noStudies = You have not participated in any studies.
|
|||
disabledList = This is a list of studies that you have participated in. No new studies will run.
|
||||
# LOCALIZATION NOTE (enabledList): %S is brandShortName (e.g. Firefox)
|
||||
enabledList = What’s this? %S may install and run studies from time to time.
|
||||
|
||||
# LOCALIZATION NOTE (preferenceStudyDescription) $1%S will be replaced with the
|
||||
# name of a preference (such as "stream.improvesearch.topSiteSearchShortcuts")
|
||||
# and $2%S will be replaced with the value of that preference. Both values will
|
||||
# be formatted differently than the surrounding text.
|
||||
preferenceStudyDescription = This study sets %1$S to %2$S.
|
Загрузка…
Ссылка в новой задаче