Backed out 3 changesets (bug 1447499) for browser chrome failures on browser_all_files_referenced. CLOSED TREE

Backed out changeset 8eeaf62be0dc (bug 1447499)
Backed out changeset 3579f8912b70 (bug 1447499)
Backed out changeset 675b77e1c236 (bug 1447499)
This commit is contained in:
Cosmin Sabou 2018-09-13 21:47:47 +03:00
Родитель 66aa4d36c4
Коммит 20460bde38
16 изменённых файлов: 778 добавлений и 700 удалений

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

@ -6,10 +6,9 @@
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, "PreferenceExperiments", "resource://normandy/lib/PreferenceExperiments.jsm");
ChromeUtils.defineModuleGetter(this, "AddonStudies", "resource://normandy/lib/AddonStudies.jsm");
ChromeUtils.defineModuleGetter(this, "RecipeRunner", "resource://normandy/lib/RecipeRunner.jsm");
var EXPORTED_SYMBOLS = ["AboutPages"];
@ -62,7 +61,7 @@ var AboutPages = {
this.aboutStudies.registerParentListeners();
CleanupManager.addCleanupHandler(() => {
// Stop loading process scripts and notify existing scripts to clean up.
// Stop loading processs scripts and notify existing scripts to clean up.
Services.ppmm.broadcastAsyncMessage("Shield:ShuttingDown");
Services.mm.broadcastAsyncMessage("Shield:ShuttingDown");
@ -96,10 +95,8 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
* Register listeners for messages from the content processes.
*/
registerParentListeners() {
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:GetStudyList", this);
Services.mm.addMessageListener("Shield:RemoveStudy", this);
Services.mm.addMessageListener("Shield:OpenDataPreferences", this);
Services.mm.addMessageListener("Shield:GetStudiesEnabled", this);
},
@ -108,10 +105,8 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
* Unregister listeners for messages from the content process.
*/
unregisterParentListeners() {
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:GetStudyList", this);
Services.mm.removeMessageListener("Shield:RemoveStudy", this);
Services.mm.removeMessageListener("Shield:OpenDataPreferences", this);
Services.mm.removeMessageListener("Shield:GetStudiesEnabled", this);
},
@ -123,17 +118,11 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
*/
receiveMessage(message) {
switch (message.name) {
case "Shield:GetAddonStudyList":
this.sendAddonStudyList(message.target);
case "Shield:GetStudyList":
this.sendStudyList(message.target);
break;
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);
case "Shield:RemoveStudy":
this.removeStudy(message.data.recipeId, message.data.reason);
break;
case "Shield:OpenDataPreferences":
this.openDataPreferences();
@ -145,15 +134,15 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
},
/**
* Fetch a list of add-on studies from storage and send it to the process
* that requested them.
* Fetch a list of 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 sendAddonStudyList(target) {
async sendStudyList(target) {
try {
target.messageManager.sendAsyncMessage("Shield:ReceiveAddonStudyList", {
target.messageManager.sendAsyncMessage("Shield:ReceiveStudyList", {
studies: await AddonStudies.getAll(),
});
} catch (err) {
@ -162,24 +151,6 @@ 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
@ -203,32 +174,19 @@ XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
},
/**
* Disable an active add-on study and remove its add-on.
* Disable an active study and remove its add-on.
* @param {String} studyName
*/
async removeAddonStudy(recipeId, reason) {
async removeStudy(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:ReceiveAddonStudyList", {
Services.mm.broadcastAsyncMessage("Shield:ReceiveStudyList", {
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,23 +42,16 @@ 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:ReceiveAddonStudyList", this);
this.mm.addMessageListener("Shield:ReceivePreferenceStudyList", this);
this.mm.addMessageListener("Shield:ReceiveStudyList", this);
this.mm.addMessageListener("Shield:ReceiveStudiesEnabled", this);
switch (event.detail.action) {
// Actions that require the parent process
case "GetRemoteValue:AddonStudyList":
this.mm.sendAsyncMessage("Shield:GetAddonStudyList");
case "GetRemoteValue:StudyList":
this.mm.sendAsyncMessage("Shield:GetStudyList");
break;
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);
case "RemoveStudy":
this.mm.sendAsyncMessage("Shield:RemoveStudy", event.detail.data);
break;
case "GetRemoteValue:StudiesEnabled":
this.mm.sendAsyncMessage("Shield:GetStudiesEnabled");
@ -96,11 +89,8 @@ class ShieldFrameChild extends ActorChild {
*/
receiveMessage(message) {
switch (message.name) {
case "Shield:ReceiveAddonStudyList":
this.triggerPageCallback("ReceiveRemoteValue:AddonStudyList", message.data.studies);
break;
case "Shield:ReceivePreferenceStudyList":
this.triggerPageCallback("ReceiveRemoteValue:PreferenceStudyList", message.data.studies);
case "Shield:ReceiveStudyList":
this.triggerPageCallback("ReceiveRemoteValue:StudyList", message.data.studies);
break;
case "Shield:ReceiveStudiesEnabled":
this.triggerPageCallback("ReceiveRemoteValue:StudiesEnabled", message.data.studiesEnabled);

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

@ -30,9 +30,28 @@ button > .button-box {
}
.about-studies-container {
display: flex;
flex-direction: row;
font-size: 1.25rem;
max-width: 960px;
margin: 0 auto;
min-height: 100%;
width: 100%;
}
#categories {
flex: 0 0;
margin: 0;
min-width: 200px;
padding: 40px 0 0;
}
#categories .category {
align-items: center;
display: flex;
flex-direction: row;
}
.main-content {
flex: 1;
}
.info-box {
@ -141,21 +160,23 @@ button > .button-box {
.study-name {
font-weight: bold;
margin-bottom: 0.3em;
white-space: pre;
}
.study-header > * {
.study-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.study-description > * {
margin-right: 5px;
}
.study-header > *:last-child {
.study-description > *:last-child {
margin-right: 0;
}
.study-description code {
font: italic 1.0rem 'Fira Mono', 'mono', 'monospace';
}
.study-actions {
flex: 0 0;
}

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

@ -17,6 +17,8 @@
<script src="resource://normandy-vendor/ReactDOM.js"></script>
<script src="resource://normandy-vendor/PropTypes.js"></script>
<script src="resource://normandy-vendor/classnames.js"></script>
<script src="resource://normandy-content/about-studies/common.js"></script>
<script src="resource://normandy-content/about-studies/shield-studies.js"></script>
<script src="resource://normandy-content/about-studies/about-studies.js"></script>
</body>
</html>

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

@ -1,24 +1,23 @@
/* 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/. */
"use strict";
/* global classnames PropTypes React ReactDOM */
/* global classnames PropTypes r React ReactDOM remoteValues ShieldStudies */
/**
* Shorthand for creating elements (to avoid using a JSX preprocessor)
* Mapping of pages displayed on the sidebar. Keys are the value used in the
* URL hash to identify the current page.
*
* Pages will appear in the sidebar in the order they are defined here. If the
* URL doesn't contain a hash, the first page will be displayed in the content area.
*/
const r = React.createElement;
/**
* Dispatches a page event to the privileged frame script for this tab.
* @param {String} action
* @param {Object} data
*/
function sendPageEvent(action, data) {
const event = new CustomEvent("ShieldPageEvent", { bubbles: true, detail: { action, data } });
document.dispatchEvent(event);
}
const PAGES = new Map([
["shieldStudies", {
name: "title",
component: ShieldStudies,
icon: "resource://normandy-content/about-studies/img/shield-logo.png",
}],
]);
/**
* Handle basic layout and routing within about:studies.
@ -27,279 +26,133 @@ class AboutStudies extends React.Component {
constructor(props) {
super(props);
this.remoteValueNameMap = {
AddonStudyList: "addonStudies",
PreferenceStudyList: "prefStudies",
ShieldLearnMoreHref: "learnMoreHref",
StudiesEnabled: "studiesEnabled",
ShieldTranslations: "translations",
let hash = new URL(window.location).hash.slice(1);
if (!PAGES.has(hash)) {
hash = "shieldStudies";
}
this.state = {
currentPageId: hash,
};
this.state = {};
for (const stateName of Object.values(this.remoteValueNameMap)) {
this.state[stateName] = null;
}
this.handleEvent = this.handleEvent.bind(this);
}
componentWillMount() {
for (const remoteName of Object.keys(this.remoteValueNameMap)) {
document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this);
sendPageEvent(`GetRemoteValue:${remoteName}`);
}
componentDidMount() {
remoteValues.shieldTranslations.subscribe(this);
window.addEventListener("hashchange", this);
}
componentWillUnmount() {
for (const remoteName of Object.keys(this.remoteValueNameMap)) {
document.removeEventListener(`ReceiveRemoteValue:${remoteName}`, this);
}
remoteValues.shieldTranslations.unsubscribe(this);
window.removeEventListener("hashchange", this);
}
/** Event handle to receive remote values from documentAddEventListener */
handleEvent({ type, detail: value }) {
const prefix = "ReceiveRemoteValue:";
if (type.startsWith(prefix)) {
const name = type.substring(prefix.length);
this.setState({ [this.remoteValueNameMap[name]]: value });
}
}
render() {
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.
if (Object.values(this.state).some(v => v === null)) {
return null;
}
return (
r("div", { className: "about-studies-container main-content" },
r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }),
r(StudyList, { translations, addonStudies, prefStudies }),
)
);
}
}
/**
* Explains the contents of the page, and offers a way to learn more and update preferences.
*/
class WhatsThisBox extends React.Component {
handleUpdateClick() {
sendPageEvent("NavigateToDataPreferences");
}
render() {
const { learnMoreHref, studiesEnabled, translations } = this.props;
return (
r("div", { className: "info-box" },
r("div", { className: "info-box-content" },
r("span", {},
studiesEnabled ? translations.enabledList : translations.disabledList,
),
r("a", { id: "shield-studies-learn-more", href: learnMoreHref }, translations.learnMore),
r("button", { id: "shield-studies-update-preferences", onClick: this.handleUpdateClick },
r("div", { className: "button-box" },
navigator.platform.includes("Win") ? translations.updateButtonWin : translations.updateButtonUnix
),
)
)
)
);
}
}
/**
* Shows a list of studies, with an option to end in-progress ones.
*/
class StudyList extends React.Component {
render() {
const { addonStudies, prefStudies, translations } = this.props;
if (!addonStudies.length && !prefStudies.length) {
return r("p", { className: "study-list-info" }, translations.noStudies);
}
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);
receiveRemoteValue(name, value) {
switch (name) {
case "ShieldTranslations": {
this.setState({ translations: value });
break;
}
default: {
console.error(`Unknown remote value ${name}`);
}
}
}
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);
}
handleEvent(event) {
const newHash = new URL(event.newURL).hash.slice(1);
if (PAGES.has(newHash)) {
this.setState({currentPageId: newHash});
}
activeStudies.sort((a, b) => b.sortDate - a.sortDate);
inactiveStudies.sort((a, b) => b.sortDate - a.sortDate);
return (
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 })
)),
),
)
);
}
}
StudyList.propTypes = {
addonStudies: PropTypes.array.isRequired,
translations: PropTypes.object.isRequired,
};
/**
* Details about an individual add-on study, with an option to end it if it is active.
*/
class AddonStudyListItem extends React.Component {
constructor(props) {
super(props);
this.handleClickRemove = this.handleClickRemove.bind(this);
}
handleClickRemove() {
sendPageEvent("RemoveAddonStudy", {
recipeId: this.props.study.recipeId,
reason: "individual-opt-out",
});
}
render() {
const { study, translations } = this.props;
const currentPageId = this.state.currentPageId;
const pageEntries = Array.from(PAGES.entries());
const currentPage = PAGES.get(currentPageId);
const { translations } = this.state;
return (
r("li", {
className: classnames("study addon-study", { disabled: !study.active }),
"data-study-name": study.name,
},
r("div", { className: "study-icon" },
study.name.replace(/-?add-?on-?/, "").replace(/-?study-?/, "").slice(0, 1)
r("div", {className: "about-studies-container"},
translations && r(Sidebar, {},
pageEntries.map(([id, page]) => (
r(SidebarItem, {
key: id,
pageId: id,
selected: id === currentPageId,
page,
translations,
})
)),
),
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.active ? translations.activeStatus : translations.completeStatus),
),
r("div", { className: "study-description" },
study.description
),
),
r("div", { className: "study-actions" },
study.active &&
r("button", { className: "remove-button", onClick: this.handleClickRemove },
r("div", { className: "button-box" },
translations.removeButton
),
)
r(Content, {},
translations && currentPage && r(currentPage.component, {translations})
),
)
);
}
}
AddonStudyListItem.propTypes = {
study: PropTypes.shape({
recipeId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
active: PropTypes.bool.isRequired,
description: PropTypes.string.isRequired,
}).isRequired,
class Sidebar extends React.Component {
render() {
return r("ul", {id: "categories"}, this.props.children);
}
}
Sidebar.propTypes = {
children: PropTypes.node,
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 {
class SidebarItem extends React.Component {
constructor(props) {
super(props);
this.handleClickRemove = this.handleClickRemove.bind(this);
this.handleClick = this.handleClick.bind(this);
}
handleClickRemove() {
sendPageEvent("RemovePreferenceStudy", {
experimentName: this.props.study.name,
reason: "individual-opt-out",
});
handleClick() {
window.location = `#${this.props.pageId}`;
}
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);
const { page, selected, translations } = this.props;
return (
r("li", {
className: classnames("study pref-study", { disabled: study.expired }),
"data-study-name": study.name,
className: classnames("category", {selected}),
onClick: this.handleClick,
},
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
),
)
page.icon && r("img", {className: "category-icon", src: page.icon}),
r("span", {className: "category-name"}, translations[page.name]),
)
);
}
}
SidebarItem.propTypes = {
pageId: PropTypes.string.isRequired,
page: PropTypes.shape({
icon: PropTypes.string,
name: PropTypes.string.isRequired,
}).isRequired,
selected: PropTypes.bool,
translations: PropTypes.object.isRequired,
};
class Content extends React.Component {
render() {
return (
r("div", {className: "main-content"},
r("div", {className: "content-box"},
this.props.children,
),
)
);
}
}
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,
Content.propTypes = {
children: PropTypes.node,
};
ReactDOM.render(r(AboutStudies), document.getElementById("app"));
ReactDOM.render(
r(AboutStudies),
document.getElementById("app"),
);

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

@ -0,0 +1,139 @@
/* 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/. */
"use strict";
/* eslint-disable no-unused-vars */
/* global PropTypes React */
/**
* Shorthand for creating elements (to avoid using a JSX preprocessor)
*/
const r = React.createElement;
/**
* Information box used at the top of listings.
*/
window.InfoBox = class InfoBox extends React.Component {
render() {
return (
r("div", {className: "info-box"},
r("div", {className: "info-box-content"},
this.props.children,
),
)
);
}
};
window.InfoBox.propTypes = {
children: PropTypes.node,
};
/**
* Button using in-product styling.
*/
window.FxButton = class FxButton extends React.Component {
render() {
return (
r("button", Object.assign({}, this.props, {children: undefined}),
r("div", {className: "button-box"},
this.props.children,
),
)
);
}
};
window.FxButton.propTypes = {
children: PropTypes.node,
};
/**
* Wrapper class for a value that is provided by the frame script.
*
* Emits a "GetRemoteValue:{name}" page event on load to fetch the initial
* value, and listens for "ReceiveRemoteValue:{name}" page callbacks to receive
* the value when it updates.
*
* @example
* const myRemoteValue = new RemoteValue("MyValue", 5);
* class MyComponent extends React.Component {
* constructor(props) {
* super(props);
* this.state = {
* myValue: null,
* };
* }
*
* componentWillMount() {
* myRemoteValue.subscribe(this);
* }
*
* componentWillUnmount() {
* myRemoteValue.unsubscribe(this);
* }
*
* receiveRemoteValue(name, value) {
* this.setState({myValue: value});
* }
*
* render() {
* return r("div", {}, this.state.myValue);
* }
* }
*/
class RemoteValue {
constructor(name, defaultValue = null) {
this.name = name;
this.handlers = [];
this.value = defaultValue;
document.addEventListener(`ReceiveRemoteValue:${this.name}`, this);
sendPageEvent(`GetRemoteValue:${this.name}`);
}
/**
* Subscribe to this value as it updates. Handlers are called with the current
* value immediately after subscribing.
* @param {Object} handler
* Object with a receiveRemoteValue(name, value) method that is called with
* the name and value of this RemoteValue when it is updated.
*/
subscribe(handler) {
this.handlers.push(handler);
handler.receiveRemoteValue(this.name, this.value);
}
/**
* Remove a previously-registered handler.
* @param {Object} handler
*/
unsubscribe(handler) {
this.handlers = this.handlers.filter(h => h !== handler);
}
handleEvent(event) {
this.value = event.detail;
for (const handler of this.handlers) {
handler.receiveRemoteValue(this.name, this.value);
}
}
}
/**
* Collection of RemoteValue instances used within the page.
*/
const remoteValues = {
studyList: new RemoteValue("StudyList"),
shieldLearnMoreHref: new RemoteValue("ShieldLearnMoreHref"),
studiesEnabled: new RemoteValue("StudiesEnabled"),
shieldTranslations: new RemoteValue("ShieldTranslations"),
};
/**
* Dispatches a page event to the privileged frame script for this tab.
* @param {String} action
* @param {Object} data
*/
function sendPageEvent(action, data) {
const event = new CustomEvent("ShieldPageEvent", {bubbles: true, detail: {action, data}});
document.dispatchEvent(event);
}

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

@ -0,0 +1,207 @@
/* 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/. */
"use strict";
/* global classnames FxButton InfoBox PropTypes r React remoteValues sendPageEvent */
window.ShieldStudies = class ShieldStudies extends React.Component {
render() {
const { translations } = this.props;
return (
r("div", {},
r(WhatsThisBox, {translations}),
r(StudyList, {translations}),
)
);
}
};
class UpdatePreferencesButton extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
sendPageEvent("NavigateToDataPreferences");
}
render() {
return r(
FxButton,
Object.assign({
id: "shield-studies-update-preferences",
onClick: this.handleClick,
}, this.props),
);
}
}
class StudyList extends React.Component {
constructor(props) {
super(props);
this.state = {
studies: null,
};
}
componentDidMount() {
remoteValues.studyList.subscribe(this);
}
componentWillUnmount() {
remoteValues.studyList.unsubscribe(this);
}
receiveRemoteValue(name, value) {
if (value) {
const studies = value.slice();
// Sort by active status, then by start date descending.
studies.sort((a, b) => {
if (a.active !== b.active) {
return a.active ? -1 : 1;
}
return b.studyStartDate - a.studyStartDate;
});
this.setState({studies});
} else {
this.setState({studies: value});
}
}
render() {
const { studies } = this.state;
const { translations } = this.props;
if (studies === null) {
// loading
return null;
}
let info = null;
if (studies.length === 0) {
info = r("p", {className: "study-list-info"}, translations.noStudies);
}
return (
r("div", {},
info,
r("ul", {className: "study-list"},
this.state.studies.map(study => (
r(StudyListItem, {key: study.name, study, translations})
))
),
)
);
}
}
class StudyListItem 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"});
}
render() {
const {study, translations} = this.props;
return (
r("li", {
className: classnames("study", {disabled: !study.active}),
"data-study-name": study.name,
},
r("div", {className: "study-icon"},
study.name.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("span", {}, "\u2022"), // &bullet;
r("span", {}, study.description),
),
),
r("div", {className: "study-actions"},
study.active &&
r(FxButton, {className: "remove-button", onClick: this.handleClickRemove}, translations.removeButton),
),
)
);
}
}
StudyListItem.propTypes = {
study: PropTypes.shape({
recipeId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
active: PropTypes.boolean,
description: PropTypes.string.isRequired,
}).isRequired,
translations: PropTypes.object.isRequired,
};
class WhatsThisBox extends React.Component {
constructor(props) {
super(props);
this.state = {
learnMoreHref: null,
studiesEnabled: null,
};
}
componentDidMount() {
remoteValues.shieldLearnMoreHref.subscribe(this);
remoteValues.studiesEnabled.subscribe(this);
}
componentWillUnmount() {
remoteValues.shieldLearnMoreHref.unsubscribe(this);
remoteValues.studiesEnabled.unsubscribe(this);
}
receiveRemoteValue(name, value) {
switch (name) {
case "ShieldLearnMoreHref": {
this.setState({ learnMoreHref: value });
break;
}
case "StudiesEnabled": {
this.setState({ studiesEnabled: value });
break;
}
default: {
console.error(`Unknown remote value ${name}`);
}
}
}
render() {
const { learnMoreHref, studiesEnabled } = this.state;
const { translations } = this.props;
let message = null;
// studiesEnabled can be null, in which case do nothing
if (studiesEnabled === false) {
message = r("span", {}, translations.disabledList);
} else if (studiesEnabled === true) {
message = r("span", {}, translations.enabledList);
}
const updateButtonKey = navigator.platform.includes("Win") ? "updateButtonWin" : "updateButtonUnix";
return (
r(InfoBox, {},
message,
r("a", {id: "shield-studies-learn-more", href: learnMoreHref}, translations.learnMore),
r(UpdatePreferencesButton, {}, translations[updateButtonKey]),
)
);
}
}

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

@ -90,14 +90,14 @@ const PreferenceBranchType = {
/**
* Asynchronously load the JSON file that stores experiment status in the profile.
*/
let gStorePromise;
let storePromise;
function ensureStorage() {
if (gStorePromise === undefined) {
if (storePromise === undefined) {
const path = OS.Path.join(OS.Constants.Path.profileDir, EXPERIMENT_FILE);
const storage = new JSONFile({path});
gStorePromise = storage.load().then(() => storage);
storePromise = storage.load().then(() => storage);
}
return gStorePromise;
return storePromise;
}
const log = LogManager.getLogger("preference-experiments");
@ -248,30 +248,23 @@ var PreferenceExperiments = {
* Test wrapper that temporarily replaces the stored experiment data with fake
* data for testing.
*/
withMockExperiments(mockExperiments = []) {
return function wrapper(testFunction) {
return async function wrappedTestFunction(...args) {
const data = {};
for (const exp of mockExperiments) {
data[exp.name] = exp;
}
const oldPromise = gStorePromise;
gStorePromise = Promise.resolve({
data,
saveSoon() { },
});
const oldObservers = experimentObservers;
experimentObservers = new Map();
try {
await testFunction(...args, mockExperiments);
} finally {
gStorePromise = oldPromise;
PreferenceExperiments.stopAllObservers();
experimentObservers = oldObservers;
}
};
withMockExperiments(testFunction) {
return async function inner(...args) {
const oldPromise = storePromise;
const mockExperiments = {};
storePromise = Promise.resolve({
data: mockExperiments,
saveSoon() { },
});
const oldObservers = experimentObservers;
experimentObservers = new Map();
try {
await testFunction(...args, mockExperiments);
} finally {
storePromise = oldPromise;
PreferenceExperiments.stopAllObservers();
experimentObservers = oldObservers;
}
};
},

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

@ -33,7 +33,7 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
addonStudyFactory({name: "test-study"}),
studyFactory({name: "test-study"}),
]),
async function testGet([study]) {
const storedStudy = await AddonStudies.get(study.recipeId);
@ -43,8 +43,8 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
addonStudyFactory(),
addonStudyFactory(),
studyFactory(),
studyFactory(),
]),
async function testGetAll(studies) {
const storedStudies = await AddonStudies.getAll();
@ -58,7 +58,7 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
addonStudyFactory({name: "test-study"}),
studyFactory({name: "test-study"}),
]),
async function testHas([study]) {
let hasStudy = await AddonStudies.has(study.recipeId);
@ -95,8 +95,8 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
addonStudyFactory({name: "test-study1"}),
addonStudyFactory({name: "test-study2"}),
studyFactory({name: "test-study1"}),
studyFactory({name: "test-study2"}),
]),
async function testClear([study1, study2]) {
const hasAll = (
@ -116,9 +116,9 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
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)}),
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)}),
]),
withSendEventStub,
withInstalledWebExtension({id: "installed@example.com"}),
@ -162,7 +162,7 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
addonStudyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
studyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
]),
withInstalledWebExtension({id: "installed@example.com"}, /* expectUninstall: */ true),
async function testInit([study], [id, addonFile]) {

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

@ -173,7 +173,7 @@ decorate_task(
decorate_task(
withSandboxManager(Assert),
withMockPreferences,
PreferenceExperiments.withMockExperiments(),
PreferenceExperiments.withMockExperiments,
async function testPreferenceStudies(sandboxManager) {
const driver = new NormandyDriver(sandboxManager);
sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});

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

@ -28,8 +28,9 @@ function experimentFactory(attrs) {
// clearAllExperimentStorage
decorate_task(
withMockExperiments([experimentFactory({ name: "test" })]),
withMockExperiments,
async function(experiments) {
experiments.test = experimentFactory({name: "test"});
ok(await PreferenceExperiments.has("test"), "Mock experiment is detected.");
await PreferenceExperiments.clearAllExperimentStorage();
ok(
@ -41,9 +42,10 @@ decorate_task(
// start should throw if an experiment with the given name already exists
decorate_task(
withMockExperiments([experimentFactory({ name: "test" })]),
withMockExperiments,
withSendEventStub,
async function(experiments, sendEventStub) {
experiments.test = experimentFactory({name: "test"});
await Assert.rejects(
PreferenceExperiments.start({
name: "test",
@ -67,9 +69,10 @@ decorate_task(
// start should throw if an experiment for the given preference is active
decorate_task(
withMockExperiments([experimentFactory({ name: "test", preferenceName: "fake.preference" })]),
withMockExperiments,
withSendEventStub,
async function(experiments, sendEventStub) {
experiments.test = experimentFactory({name: "test", preferenceName: "fake.preference"});
await Assert.rejects(
PreferenceExperiments.start({
name: "different",
@ -93,7 +96,7 @@ decorate_task(
// start should throw if an invalid preferenceBranchType is given
decorate_task(
withMockExperiments(),
withMockExperiments,
withSendEventStub,
async function(experiments, sendEventStub) {
await Assert.rejects(
@ -120,7 +123,7 @@ decorate_task(
// start should save experiment data, modify the preference, and register a
// watcher.
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "startObserver"),
withSendEventStub,
@ -136,7 +139,7 @@ decorate_task(
preferenceBranchType: "default",
preferenceType: "string",
});
ok(await PreferenceExperiments.get("test"), "start saved the experiment");
ok("test" in experiments, "start saved the experiment");
ok(
startObserverStub.calledWith("test", "fake.preference", "string", "newvalue"),
"start registered an observer",
@ -153,8 +156,7 @@ decorate_task(
preferenceBranchType: "default",
};
const experiment = {};
const actualExperiment = await PreferenceExperiments.get("test");
Object.keys(expectedExperiment).forEach(key => experiment[key] = actualExperiment[key]);
Object.keys(expectedExperiment).forEach(key => experiment[key] = experiments.test[key]);
Assert.deepEqual(experiment, expectedExperiment, "start saved the experiment");
is(
@ -177,7 +179,7 @@ decorate_task(
// start should modify the user preference for the user branch type
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "startObserver"),
async function(experiments, mockPreferences, startObserver) {
@ -209,8 +211,7 @@ decorate_task(
};
const experiment = {};
const actualExperiment = await PreferenceExperiments.get("test");
Object.keys(expectedExperiment).forEach(key => experiment[key] = actualExperiment[key]);
Object.keys(expectedExperiment).forEach(key => experiment[key] = experiments.test[key]);
Assert.deepEqual(experiment, expectedExperiment, "start saved the experiment");
Assert.notEqual(
@ -253,7 +254,7 @@ decorate_task(
// startObserver should throw if an observer for the experiment is already
// active.
decorate_task(
withMockExperiments(),
withMockExperiments,
async function() {
PreferenceExperiments.startObserver("test", "fake.preference", "string", "newvalue");
Assert.throws(
@ -268,7 +269,7 @@ decorate_task(
// startObserver should register an observer that calls stop when a preference
// changes from its experimental value.
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
async function(mockExperiments, mockPreferences) {
const tests = [
@ -299,7 +300,7 @@ decorate_task(
);
decorate_task(
withMockExperiments(),
withMockExperiments,
async function testHasObserver() {
PreferenceExperiments.startObserver("test", "fake.preference", "string", "experimentValue");
@ -315,7 +316,7 @@ decorate_task(
// stopObserver should throw if there is no observer active for it to stop.
decorate_task(
withMockExperiments(),
withMockExperiments,
async function() {
Assert.throws(
() => PreferenceExperiments.stopObserver("neveractive", "another.fake", "othervalue"),
@ -327,7 +328,7 @@ decorate_task(
// stopObserver should cancel an active observer.
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
async function(mockExperiments, mockPreferences) {
const stop = sinon.stub(PreferenceExperiments, "stop");
@ -356,7 +357,7 @@ decorate_task(
// stopAllObservers
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
async function(mockExperiments, mockPreferences) {
const stop = sinon.stub(PreferenceExperiments, "stop");
@ -389,7 +390,7 @@ decorate_task(
// markLastSeen should throw if it can't find a matching experiment
decorate_task(
withMockExperiments(),
withMockExperiments,
async function() {
await Assert.rejects(
PreferenceExperiments.markLastSeen("neveractive"),
@ -400,13 +401,14 @@ decorate_task(
);
// markLastSeen should update the lastSeen date
const oldDate = new Date(1988, 10, 1).toJSON();
decorate_task(
withMockExperiments([experimentFactory({ name: "test", lastSeen: oldDate })]),
async function([experiment]) {
withMockExperiments,
async function(experiments) {
const oldDate = new Date(1988, 10, 1).toJSON();
experiments.test = experimentFactory({name: "test", lastSeen: oldDate});
await PreferenceExperiments.markLastSeen("test");
Assert.notEqual(
experiment.lastSeen,
experiments.test.lastSeen,
oldDate,
"markLastSeen updated the experiment lastSeen date",
);
@ -415,7 +417,7 @@ decorate_task(
// stop should throw if an experiment with the given name doesn't exist
decorate_task(
withMockExperiments(),
withMockExperiments,
withSendEventStub,
async function(experiments, sendEventStub) {
await Assert.rejects(
@ -434,9 +436,10 @@ decorate_task(
// stop should throw if the experiment is already expired
decorate_task(
withMockExperiments([experimentFactory({ name: "test", expired: true })]),
withMockExperiments,
withSendEventStub,
async function(experiments, sendEventStub) {
experiments.test = experimentFactory({name: "test", expired: true});
await Assert.rejects(
PreferenceExperiments.stop("test"),
/already expired/,
@ -454,18 +457,7 @@ decorate_task(
// stop should mark the experiment as expired, stop its observer, and revert the
// preference value.
decorate_task(
withMockExperiments([
experimentFactory({
name: "test",
expired: false,
branch: "fakebranch",
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "default",
}),
]),
withMockExperiments,
withMockPreferences,
withSpy(PreferenceExperiments, "stopObserver"),
withSendEventStub,
@ -476,12 +468,21 @@ decorate_task(
mockPreferences.set(`${startupPrefs}.fake.preference`, "experimentvalue", "user");
mockPreferences.set("fake.preference", "experimentvalue", "default");
experiments.test = experimentFactory({
name: "test",
expired: false,
branch: "fakebranch",
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "default",
});
PreferenceExperiments.startObserver("test", "fake.preference", "string", "experimentvalue");
await PreferenceExperiments.stop("test", {reason: "test-reason"});
ok(stopObserverSpy.calledWith("test"), "stop removed an observer");
const experiment = await PreferenceExperiments.get("test");
is(experiment.expired, true, "stop marked the experiment as expired");
is(experiments.test.expired, true, "stop marked the experiment as expired");
is(
DefaultPreferences.get("fake.preference"),
"oldvalue",
@ -494,7 +495,7 @@ decorate_task(
Assert.deepEqual(
sendEventStub.args,
[["unenroll", "preference_study", "test", {
[["unenroll", "preference_study", experiments.test.name, {
didResetValue: "true",
reason: "test-reason",
branch: "fakebranch",
@ -508,15 +509,7 @@ decorate_task(
// stop should also support user pref experiments
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
expired: false,
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "user",
})]),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "stopObserver"),
withStub(PreferenceExperiments, "hasObserver"),
@ -524,12 +517,20 @@ decorate_task(
hasObserver.returns(true);
mockPreferences.set("fake.preference", "experimentvalue", "user");
experiments.test = experimentFactory({
name: "test",
expired: false,
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "user",
});
PreferenceExperiments.startObserver("test", "fake.preference", "string", "experimentvalue");
await PreferenceExperiments.stop("test");
ok(stopObserver.calledWith("test"), "stop removed an observer");
const experiment = await PreferenceExperiments.get("test");
is(experiment.expired, true, "stop marked the experiment as expired");
is(experiments.test.expired, true, "stop marked the experiment as expired");
is(
Preferences.get("fake.preference"),
"oldvalue",
@ -542,19 +543,20 @@ decorate_task(
// stop should remove a preference that had no value prior to an experiment for user prefs
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
expired: false,
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: null,
preferenceBranchType: "user",
})]),
withMockExperiments,
withMockPreferences,
async function(experiments, mockPreferences) {
const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
mockPreferences.set("fake.preference", "experimentvalue", "user");
experiments.test = experimentFactory({
name: "test",
expired: false,
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: null,
preferenceBranchType: "user",
});
await PreferenceExperiments.stop("test");
ok(
@ -568,21 +570,22 @@ decorate_task(
// stop should not modify a preference if resetValue is false
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
expired: false,
branch: "fakebranch",
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "default",
})]),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "stopObserver"),
withSendEventStub,
async function testStopReset(experiments, mockPreferences, stopObserverStub, sendEventStub) {
mockPreferences.set("fake.preference", "customvalue", "default");
experiments.test = experimentFactory({
name: "test",
expired: false,
branch: "fakebranch",
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "default",
});
await PreferenceExperiments.stop("test", {reason: "test-reason", resetValue: false});
is(
@ -592,7 +595,7 @@ decorate_task(
);
Assert.deepEqual(
sendEventStub.args,
[["unenroll", "preference_study", "test", {
[["unenroll", "preference_study", experiments.test.name, {
didResetValue: "false",
reason: "test-reason",
branch: "fakebranch",
@ -604,7 +607,7 @@ decorate_task(
// get should throw if no experiment exists with the given name
decorate_task(
withMockExperiments(),
withMockExperiments,
async function() {
await Assert.rejects(
PreferenceExperiments.get("neverexisted"),
@ -616,25 +619,29 @@ decorate_task(
// get
decorate_task(
withMockExperiments([experimentFactory({ name: "test" })]),
withMockExperiments,
async function(experiments) {
const experiment = await PreferenceExperiments.get("test");
is(experiment.name, "test", "get fetches the correct experiment");
const experiment = experimentFactory({name: "test"});
experiments.test = experiment;
const fetchedExperiment = await PreferenceExperiments.get("test");
Assert.deepEqual(fetchedExperiment, experiment, "get fetches the correct experiment");
// Modifying the fetched experiment must not edit the data source.
experiment.name = "othername";
const refetched = await PreferenceExperiments.get("test");
is(refetched.name, "test", "get returns a copy of the experiment");
fetchedExperiment.name = "othername";
is(experiments.test.name, "test", "get returns a copy of the experiment");
}
);
// get all
decorate_task(
withMockExperiments([
experimentFactory({ name: "experiment1", disabled: false }),
experimentFactory({ name: "experiment2", disabled: true }),
]),
async function testGetAll([experiment1, experiment2]) {
withMockExperiments,
async function testGetAll(experiments) {
const experiment1 = experimentFactory({name: "experiment1"});
const experiment2 = experimentFactory({name: "experiment2", disabled: true});
experiments.experiment1 = experiment1;
experiments.experiment2 = experiment2;
const fetchedExperiments = await PreferenceExperiments.getAll();
is(fetchedExperiments.length, 2, "getAll returns a list of all stored experiments");
Assert.deepEqual(
@ -656,29 +663,28 @@ decorate_task(
// get all active
decorate_task(
withMockExperiments([
experimentFactory({
withMockExperiments,
withMockPreferences,
async function testGetAllActive(experiments) {
experiments.active = experimentFactory({
name: "active",
expired: false,
}),
experimentFactory({
});
experiments.inactive = experimentFactory({
name: "inactive",
expired: true,
}),
]),
withMockPreferences,
async function testGetAllActive([activeExperiment, inactiveExperiment]) {
let allActiveExperiments = await PreferenceExperiments.getAllActive();
});
const activeExperiments = await PreferenceExperiments.getAllActive();
Assert.deepEqual(
allActiveExperiments,
[activeExperiment],
activeExperiments,
[experiments.active],
"getAllActive only returns active experiments",
);
allActiveExperiments[0].name = "newfakename";
allActiveExperiments = await PreferenceExperiments.getAllActive();
activeExperiments[0].name = "newfakename";
Assert.notEqual(
allActiveExperiments,
experiments.active.name,
"newfakename",
"getAllActive returns copies of stored experiments",
);
@ -687,8 +693,9 @@ decorate_task(
// has
decorate_task(
withMockExperiments([experimentFactory({ name: "test" })]),
withMockExperiments,
async function(experiments) {
experiments.test = experimentFactory({name: "test"});
ok(await PreferenceExperiments.has("test"), "has returned true for a stored experiment");
ok(!(await PreferenceExperiments.has("missing")), "has returned false for a missing experiment");
}
@ -696,22 +703,25 @@ decorate_task(
// init should register telemetry experiments
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
branch: "branch",
preferenceName: "fake.pref",
preferenceValue: "experiment value",
expired: false,
preferenceBranchType: "default",
})]),
withMockExperiments,
withMockPreferences,
withStub(TelemetryEnvironment, "setExperimentActive"),
withStub(PreferenceExperiments, "startObserver"),
async function testInit(experiments, mockPreferences, setActiveStub, startObserverStub) {
mockPreferences.set("fake.pref", "experiment value");
experiments.test = experimentFactory({
name: "test",
branch: "branch",
preferenceName: "fake.pref",
preferenceValue: "experiment value",
expired: false,
preferenceBranchType: "default",
});
await PreferenceExperiments.init();
ok(
setActiveStub.calledWith("test", "branch", { type: "normandy-exp" }),
setActiveStub.calledWith("test", "branch", {type: "normandy-exp"}),
"Experiment is registered by init",
);
},
@ -719,21 +729,24 @@ decorate_task(
// init should use the provided experiment type
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
branch: "branch",
preferenceName: "fake.pref",
preferenceValue: "experiment value",
experimentType: "pref-test",
})]),
withMockExperiments,
withMockPreferences,
withStub(TelemetryEnvironment, "setExperimentActive"),
withStub(PreferenceExperiments, "startObserver"),
async function testInit(experiments, mockPreferences, setActiveStub, startObserverStub) {
mockPreferences.set("fake.pref", "experiment value");
experiments.test = experimentFactory({
name: "test",
branch: "branch",
preferenceName: "fake.pref",
preferenceValue: "experiment value",
experimentType: "pref-test",
});
await PreferenceExperiments.init();
ok(
setActiveStub.calledWith("test", "branch", { type: "normandy-pref-test" }),
setActiveStub.calledWith("test", "branch", {type: "normandy-pref-test"}),
"init should use the provided experiment type",
);
},
@ -741,7 +754,7 @@ decorate_task(
// starting and stopping experiments should register in telemetry
decorate_task(
withMockExperiments(),
withMockExperiments,
withStub(TelemetryEnvironment, "setExperimentActive"),
withStub(TelemetryEnvironment, "setExperimentInactive"),
withSendEventStub,
@ -757,10 +770,10 @@ decorate_task(
Assert.deepEqual(
setActiveStub.getCall(0).args,
["test", "branch", { type: "normandy-exp" }],
["test", "branch", {type: "normandy-exp"}],
"Experiment is registered by start()",
);
await PreferenceExperiments.stop("test", { reason: "test-reason" });
await PreferenceExperiments.stop("test", {reason: "test-reason"});
Assert.deepEqual(setInactiveStub.args, [["test"]], "Experiment is unregistered by stop()");
Assert.deepEqual(
@ -783,7 +796,7 @@ decorate_task(
// starting experiments should use the provided experiment type
decorate_task(
withMockExperiments(),
withMockExperiments,
withStub(TelemetryEnvironment, "setExperimentActive"),
withStub(TelemetryEnvironment, "setExperimentInactive"),
withSendEventStub,
@ -800,7 +813,7 @@ decorate_task(
Assert.deepEqual(
setActiveStub.getCall(0).args,
["test", "branch", { type: "normandy-pref-test" }],
["test", "branch", {type: "normandy-pref-test"}],
"start() should register the experiment with the provided type",
);
@ -821,9 +834,10 @@ decorate_task(
// Experiments shouldn't be recorded by init() in telemetry if they are expired
decorate_task(
withMockExperiments([experimentFactory({ name: "expired", branch: "branch", expired: true })]),
withMockExperiments,
withStub(TelemetryEnvironment, "setExperimentActive"),
async function testInitTelemetryExpired(experiments, setActiveStub) {
experiments.experiment1 = experimentFactory({name: "expired", branch: "branch", expired: true});
await PreferenceExperiments.init();
ok(!setActiveStub.called, "Expired experiment is not registered by init");
},
@ -831,16 +845,17 @@ decorate_task(
// Experiments should end if the preference has been changed when init() is called
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
preferenceName: "fake.preference",
preferenceValue: "experiment value",
})]),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "stop"),
async function testInitChanges(experiments, mockPreferences, stopStub) {
mockPreferences.set("fake.preference", "experiment value", "default");
mockPreferences.set("fake.preference", "changed value", "user");
experiments.test = experimentFactory({
name: "test",
preferenceName: "fake.preference",
preferenceValue: "experiment value",
});
mockPreferences.set("fake.preference", "changed value");
await PreferenceExperiments.init();
is(Preferences.get("fake.preference"), "changed value", "Preference value was not changed");
@ -858,11 +873,7 @@ decorate_task(
// init should register an observer for experiments
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
preferenceName: "fake.preference",
preferenceValue: "experiment value",
})]),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "startObserver"),
withStub(PreferenceExperiments, "stop"),
@ -871,6 +882,11 @@ decorate_task(
stop.throws("Stop should not be called");
mockPreferences.set("fake.preference", "experiment value", "default");
is(Preferences.get("fake.preference"), "experiment value", "pref shouldn't have a user value");
experiments.test = experimentFactory({
name: "test",
preferenceName: "fake.preference",
preferenceValue: "experiment value",
});
await PreferenceExperiments.init();
ok(startObserver.calledOnce, "init should register an observer");
@ -884,24 +900,21 @@ decorate_task(
// saveStartupPrefs
decorate_task(
withMockExperiments([
experimentFactory({
name: "char",
preferenceName: `fake.char`,
preferenceValue: "string",
}),
experimentFactory({
name: "int",
preferenceName: `fake.int`,
preferenceValue: 2,
}),
experimentFactory({
name: "bool",
preferenceName: `fake.bool`,
preferenceValue: true,
}),
]),
withMockExperiments,
async function testSaveStartupPrefs(experiments) {
const experimentPrefs = {
char: "string",
int: 2,
bool: true,
};
for (const [key, value] of Object.entries(experimentPrefs)) {
experiments[key] = experimentFactory({
preferenceName: `fake.${key}`,
preferenceValue: value,
});
}
Services.prefs.deleteBranch(startupPrefs);
Services.prefs.setBoolPref(`${startupPrefs}.fake.old`, true);
await PreferenceExperiments.saveStartupPrefs();
@ -929,12 +942,13 @@ decorate_task(
// saveStartupPrefs errors for invalid pref type
decorate_task(
withMockExperiments([experimentFactory({
name: "test",
preferenceName: "fake.invalidValue",
preferenceValue: new Date(),
})]),
withMockExperiments,
async function testSaveStartupPrefsError(experiments) {
experiments.test = experimentFactory({
preferenceName: "fake.invalidValue",
preferenceValue: new Date(),
});
await Assert.rejects(
PreferenceExperiments.saveStartupPrefs(),
/invalid preference type/i,
@ -945,21 +959,19 @@ decorate_task(
// saveStartupPrefs should not store values for user-branch recipes
decorate_task(
withMockExperiments([
experimentFactory({
name: "defaultBranchRecipe",
withMockExperiments,
async function testSaveStartupPrefsUserBranch(experiments) {
experiments.defaultBranchRecipe = experimentFactory({
preferenceName: "fake.default",
preferenceValue: "experiment value",
branch: "default",
}),
experimentFactory({
name: "userBranchRecipe",
});
experiments.userBranchRecipe = experimentFactory({
preferenceName: "fake.user",
preferenceValue: "experiment value",
branch: "user",
}),
]),
async function testSaveStartupPrefsUserBranch(experiments) {
});
await PreferenceExperiments.saveStartupPrefs();
is(
@ -979,7 +991,7 @@ decorate_task(
// test that default branch prefs restore to the right value if the default pref changes
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "startObserver"),
withStub(PreferenceExperiments, "stopObserver"),
@ -1027,7 +1039,7 @@ decorate_task(
// test that default branch prefs restore to the right value if the preference is removed
decorate_task(
withMockExperiments(),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "startObserver"),
withStub(PreferenceExperiments, "stopObserver"),
@ -1074,12 +1086,13 @@ decorate_task(
// stop should pass "unknown" to telemetry event for `reason` if none is specified
decorate_task(
withMockExperiments([experimentFactory({ name: "test", preferenceName: "fake.preference" })]),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "stopObserver"),
withSendEventStub,
async function testStopUnknownReason(experiments, mockPreferences, stopObserverStub, sendEventStub) {
mockPreferences.set("fake.preference", "default value", "default");
experiments.test = experimentFactory({ name: "test", preferenceName: "fake.preference" });
await PreferenceExperiments.stop("test");
is(
sendEventStub.getCall(0).args[3].reason,
@ -1091,16 +1104,14 @@ decorate_task(
// stop should pass along the value for resetValue to Telemetry Events as didResetValue
decorate_task(
withMockExperiments([
experimentFactory({ name: "test1", preferenceName: "fake.preference1" }),
experimentFactory({ name: "test2", preferenceName: "fake.preference2" }),
]),
withMockExperiments,
withMockPreferences,
withStub(PreferenceExperiments, "stopObserver"),
withSendEventStub,
async function testStopResetValue(experiments, mockPreferences, stopObserverStub, sendEventStub) {
mockPreferences.set("fake.preference1", "default value", "default");
await PreferenceExperiments.stop("test1", { resetValue: true });
experiments.test1 = experimentFactory({ name: "test1", preferenceName: "fake.preference1" });
await PreferenceExperiments.stop("test1", {resetValue: true});
is(sendEventStub.callCount, 1);
is(
sendEventStub.getCall(0).args[3].didResetValue,
@ -1109,7 +1120,8 @@ decorate_task(
);
mockPreferences.set("fake.preference2", "default value", "default");
await PreferenceExperiments.stop("test2", { resetValue: false });
experiments.test2 = experimentFactory({ name: "test2", preferenceName: "fake.preference2" });
await PreferenceExperiments.stop("test2", {resetValue: false});
is(sendEventStub.callCount, 2);
is(
sendEventStub.getCall(1).args[3].didResetValue,
@ -1124,19 +1136,20 @@ decorate_task(
decorate_task(
withMockPreferences,
withSendEventStub,
withMockExperiments([experimentFactory({
name: "test",
expired: false,
branch: "fakebranch",
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "default",
})]),
withMockExperiments,
async function testPrefChangeEventTelemetry(mockPreferences, sendEventStub, mockExperiments) {
is(Preferences.get("fake.preference"), null, "preference should start unset");
mockPreferences.set("fake.preference", "oldvalue", "default");
mockExperiments.test = experimentFactory({
name: "test",
expired: false,
branch: "fakebranch",
preferenceName: "fake.preference",
preferenceValue: "experimentvalue",
preferenceType: "string",
previousPreferenceValue: "oldvalue",
preferenceBranchType: "default",
});
PreferenceExperiments.startObserver("test", "fake.preference", "string", "experimentvalue");
// setting the preference on the user branch should trigger the observer to stop the experiment

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

@ -11,8 +11,8 @@ ShieldPreferences.init();
decorate_task(
withMockPreferences,
AddonStudies.withStudies([
addonStudyFactory({active: true}),
addonStudyFactory({active: true}),
studyFactory({active: true}),
studyFactory({active: true}),
]),
async function testDisableStudiesWhenOptOutDisabled(mockPreferences, [study1, study2]) {

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

@ -1,19 +1,17 @@
"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) {
@ -22,7 +20,6 @@ decorate_task(
}
);
// Test that the learn more element is displayed correctly
decorate_task(
withPrefEnv({
set: [["app.normandy.shieldLearnMoreUrl", "http://test/%OS%/"]],
@ -44,10 +41,9 @@ decorate_task(
}
);
// Test that jumping to preferences worked as expected
decorate_task(
withAboutStudies,
async function testUpdatePreferences(browser) {
async function testUpdatePreferencesNewOrganization(browser) {
let loadPromise = BrowserTestUtils.firstBrowserLoaded(window);
// We have to use gBrowser instead of browser in most spots since we're
@ -73,159 +69,90 @@ decorate_task(
decorate_task(
AddonStudies.withStudies([
addonStudyFactory({
name: "A Fake Add-on Study",
// Sort order should be study3, study1, study2 (order by enabled, then most recent).
studyFactory({
name: "A Fake Study",
active: true,
description: "A fake description",
studyStartDate: new Date(2018, 0, 4),
studyStartDate: new Date(2017),
}),
addonStudyFactory({
name: "B Fake Add-on Study",
studyFactory({
name: "B Fake Study",
active: false,
description: "B fake description",
studyStartDate: new Date(2018, 0, 2),
description: "A fake description",
studyStartDate: new Date(2019),
}),
addonStudyFactory({
name: "C Fake Add-on Study",
studyFactory({
name: "C Fake Study",
active: true,
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,
description: "A fake description",
studyStartDate: new Date(2018),
}),
]),
withAboutStudies,
async function testStudyListing(addonStudies, prefStudies, browser) {
await ContentTask.spawn(browser, { addonStudies, prefStudies }, async ({ addonStudies, prefStudies }) => {
async function testStudyListing([study1, study2, study3], browser) {
await ContentTask.spawn(browser, [study1, study2, study3], async ([cStudy1, cStudy2, cStudy3]) => {
const doc = content.document;
function getStudyRow(docElem, studyName) {
return docElem.querySelector(`.study[data-study-name="${studyName}"]`);
}
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);
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list .study").length);
const studyRows = doc.querySelectorAll(".study-list .study");
const names = Array.from(studyRows).map(row => row.querySelector(".study-name").textContent);
Assert.deepEqual(
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",
names,
[cStudy3.name, cStudy1.name, cStudy2.name],
"Studies are sorted first by enabled status, and then by descending start date."
);
const activeAddonStudy = getStudyRow(doc, addonStudies[0].name);
const study1Row = getStudyRow(doc, cStudy1.name);
ok(
activeAddonStudy.querySelector(".study-description").textContent.includes(addonStudies[0].description),
study1Row.querySelector(".study-description").textContent.includes(cStudy1.description),
"Study descriptions are shown in about:studies."
);
is(
activeAddonStudy.querySelector(".study-status").textContent,
study1Row.querySelector(".study-status").textContent,
"Active",
"Active studies show an 'Active' indicator."
);
ok(
activeAddonStudy.querySelector(".remove-button"),
study1Row.querySelector(".remove-button"),
"Active studies show a remove button"
);
is(
activeAddonStudy.querySelector(".study-icon").textContent.toLowerCase(),
study1Row.querySelector(".study-icon").textContent.toLowerCase(),
"a",
"Study icons use the first letter of the study name."
);
const inactiveAddonStudy = getStudyRow(doc, addonStudies[1].name);
const study2Row = getStudyRow(doc, cStudy2.name);
is(
inactiveAddonStudy.querySelector(".study-status").textContent,
study2Row.querySelector(".study-status").textContent,
"Complete",
"Inactive studies are marked as complete."
);
ok(
!inactiveAddonStudy.querySelector(".remove-button"),
!study2Row.querySelector(".remove-button"),
"Inactive studies do not show a remove button"
);
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();
study1Row.querySelector(".remove-button").click();
await ContentTaskUtils.waitForCondition(() => (
getStudyRow(doc, addonStudies[0].name).matches(".study--disabled")
getStudyRow(doc, cStudy1.name).matches(".disabled")
));
ok(
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"),
getStudyRow(doc, cStudy1.name).matches(".disabled"),
"Clicking the remove button updates the UI to show that the study has been disabled."
);
});
const updatedAddonStudy = await AddonStudies.get(addonStudies[0].recipeId);
const updatedStudy1 = await AddonStudies.get(study1.recipeId);
ok(
!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."
!updatedStudy1.active,
"Clicking the remove button marks the study as inactive in storage."
);
}
);
@ -236,7 +163,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-info").length);
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list").length);
const studyRows = doc.querySelectorAll(".study-list .study");
is(studyRows.length, 0, "There should be no studies");
is(
@ -256,7 +183,7 @@ decorate_task(
await ContentTask.spawn(browser, null, async () => {
const doc = content.document;
await ContentTaskUtils.waitForCondition(() => doc.querySelector(".info-box-content > span"));
await ContentTaskUtils.waitForCondition(() => !!doc.querySelector(".info-box-content > span"));
is(
doc.querySelector(".info-box-content > span").textContent,
@ -269,4 +196,4 @@ decorate_task(
RecipeRunner.checkPrefs();
}
}
).only();
);

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

@ -41,7 +41,7 @@ function ensureAddonCleanup(testFunction) {
// Test that enroll is not called if recipe is already enrolled
decorate_task(
ensureAddonCleanup,
AddonStudies.withStudies([addonStudyFactory()]),
AddonStudies.withStudies([studyFactory()]),
withSendEventStub,
async function enrollTwiceFail([study], sendEventStub) {
const recipe = recipeFactory({
@ -176,7 +176,7 @@ decorate_task(
decorate_task(
ensureAddonCleanup,
AddonStudies.withStudies([
addonStudyFactory({active: false}),
studyFactory({active: false}),
]),
withSendEventStub,
async ([study], sendEventStub) => {
@ -194,7 +194,7 @@ const testStopId = "testStop@example.com";
decorate_task(
ensureAddonCleanup,
AddonStudies.withStudies([
addonStudyFactory({active: true, addonId: testStopId, studyEndDate: null}),
studyFactory({active: true, addonId: testStopId, studyEndDate: null}),
]),
withInstalledWebExtension({id: testStopId}, /* expectUninstall: */ true),
withSendEventStub,
@ -228,7 +228,7 @@ decorate_task(
decorate_task(
ensureAddonCleanup,
AddonStudies.withStudies([
addonStudyFactory({active: true, addonId: "missingAddon@example.com", studyEndDate: null}),
studyFactory({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([addonStudyFactory()]),
AddonStudies.withStudies([studyFactory()]),
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 _addonStudyFactoryId = 0;
this.addonStudyFactory = function(attrs) {
let _studyFactoryId = 0;
this.studyFactory = function(attrs) {
return Object.assign({
recipeId: _addonStudyFactoryId++,
recipeId: _studyFactoryId++,
name: "Test study",
description: "fake",
active: true,
@ -294,22 +294,6 @@ this.addonStudyFactory = 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,12 +8,9 @@
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
@ -24,9 +21,3 @@ 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.