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:
Michael Cooper 2018-09-13 19:53:19 +00:00
Родитель d15f0d0fa0
Коммит 3c8bd4ab57
10 изменённых файлов: 370 добавлений и 130 удалений

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

@ -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"), // &bullet;
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"), // &bullet;
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 = Whats 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.