diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/ion/content/ion.css | 183 | ||||
-rw-r--r-- | browser/components/ion/content/ion.ftl | 83 | ||||
-rw-r--r-- | browser/components/ion/content/ion.html | 129 | ||||
-rw-r--r-- | browser/components/ion/content/ion.js | 792 | ||||
-rw-r--r-- | browser/components/ion/jar.mn | 8 | ||||
-rw-r--r-- | browser/components/ion/moz.build | 17 | ||||
-rw-r--r-- | browser/components/ion/schemas/IonContentSchema.json | 39 | ||||
-rw-r--r-- | browser/components/ion/schemas/IonStudyAddonsSchema.json | 159 | ||||
-rw-r--r-- | browser/components/ion/test/browser/browser.ini | 3 | ||||
-rw-r--r-- | browser/components/ion/test/browser/browser_ion_ui.js | 1133 |
10 files changed, 2546 insertions, 0 deletions
diff --git a/browser/components/ion/content/ion.css b/browser/components/ion/content/ion.css new file mode 100644 index 0000000000..fd2fee1eb5 --- /dev/null +++ b/browser/components/ion/content/ion.css @@ -0,0 +1,183 @@ +/* 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/. */ + +body { + margin: 40px auto; + max-width: 664px; + font-family: 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif; +} + +@media (max-width: 830px) { + body { + margin-inline-start: 16px; + margin-inline-end: 16px; + } +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-end: 24px; +} + +@media (max-width: 600px) { + header { + display: block; + } +} + +#locale-notification { + display: none; + text-align: center; +} + +#summary, #details, #data { + margin-block-end: 24px; +} + +#summary, #summary, #details p, #data p { + font-size: 15px; + line-height: 22px; +} + +#data ul { + padding-inline-start: 15px; +} + +#data ul li { + margin-block-end: 8px; +} + +#data a, #data strong { + font-weight: 600; +} + +h2 { + font-size: 17px; + font-weight: 600; +} + +details > summary { + user-select: none; + padding: 2px 6px; + width: 18em; + cursor: pointer; + outline: none; + font-size: 17px; + font-weight: 600; +} + +#report-title, #title { + font-size: 1.46em; + font-weight: 300; + line-height: 1.3em; + margin-block-end: 0; +} + +@media (max-width: 600px) { + #title { + margin-block-end: 8px; + } + #enrollment-button { + margin-inline-start: 0; + } +} + +#available-studies { + font-weight: 600; +} + +.card { + display: flex; + flex-wrap: wrap; +} + +.card-icon { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.card-body { + flex-grow: 1; + margin-inline-start: 16px; +} + +.card-name { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1; +} + +.card-author { + margin: 0; + font-size: 14px; + font-weight: 400; +} + +.card-actions { + align-self: center; + flex-shrink: 0; + min-width: 120px; +} + +.join-button { + max-width: 200px; + margin: 0; + margin-inline-end: 16px; +} + +.card-description { + font-size: 14px; + font-weight: normal; + width: 100%; +} + +.card-data-collected { + font-size: 14px; + font-weight: normal; +} + +*[hidden] { + display: none !important; +} + +#ion-icon { + -moz-context-properties: fill; + fill: currentColor; +} + +.modal { + max-height: 90%; + max-width: min(700px, 80%); + overflow: auto; + background: var(--in-content-page-background); + color: var(--in-content-page-color); + border: 1px solid transparent; + border-radius: 3.5px; + box-shadow: 0 2px 6px 0 rgba(0,0,0,0.3); + padding: 8px 16px 0; +} + +.modal > footer { + display: flex; + justify-content: flex-end; + padding-bottom: 16px; +} + +.modal::backdrop { + background-color: rgba(0,0,0,.5); +} + +h1 > p { margin-bottom: 0} + +.consent-list { + padding: 16px; + padding-inline-start: 32px; + margin-block-end: 16px; + border: 1px solid var(--in-content-box-border-color); + background: var(--in-content-box-background); +} diff --git a/browser/components/ion/content/ion.ftl b/browser/components/ion/content/ion.ftl new file mode 100644 index 0000000000..6762eca7b1 --- /dev/null +++ b/browser/components/ion/content/ion.ftl @@ -0,0 +1,83 @@ +# 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/. + +### This file is not in a locales directory to prevent it from +### being translated as the feature is still in heavy development +### and strings are likely to change often. + +-ion-brand-short-name = Ion +ion = { -ion-brand-short-name } +ion-document-title = Put your data to work for a better internet +ion-summary = { -ion-brand-short-name } puts your data to work to address some of today’s most pressing technology concerns, like misinformation, data privacy, and ethical AI. The data you agree to share with Mozilla (the makers of Firefox) helps create tools for better internet transparency and design products that give control back to the people who use them. As an { -ion-brand-short-name } participant, you’ll also have the option to contribute your data to studies sponsored by research institutions and other organizations. +ion-study-prompt = Join to enroll +ion-join-study = Join Study +ion-leave-study = Leave Study +ion-enrollment-button = Join { -ion-brand-short-name } +ion-unenrollment-button = Leave { -ion-brand-short-name } +ion-current-studies = Current Studies +ion-no-current-studies = No current studies, please check back later. +ion-end-study = End Study +ion-ended-study = Study Ended +ion-accept-participate = Accept and Participate +ion-accept-leave = Accept and Leave +ion-cancel = Cancel +ion-consent-notice = { -ion-brand-short-name } Privacy Notice +ion-consent-study-notice = { -ion-brand-short-name } Study Privacy Consent Notice + +leave-ion-consent-title = You’re leaving? +leave-ion-consent-bullet-thanks = Thank you for participating. +leave-ion-consent-bullet-manage = We’re sorry to see you go. Once you leave { -ion-brand-short-name }, we will stop all data collection and unenroll you from any active studies. We will also delete the data you’ve contributed, where applicable. <a data-l10n-name="privacy-policy">Learn more about managing your { -ion-brand-short-name } data</a>. + +leave-study-consent-title = Withdraw your contribution to this study? +leave-study-consent-bullet-manage = If you unenroll while this study is still active, { -ion-brand-short-name } will rescind your contribution to this research effort and will delete any data you’ve already contributed. <a data-l10n-name="privacy-policy">Learn more about managing your { -ion-brand-short-name } data</a>. + +ion-consent-study-title = Leaving Study +ion-consent-study-join = Accept and Join Study +ion-consent-study-leave = Accept and Leave Study + +ion-program-consent-intro = When you enroll in { -ion-brand-short-name }, you are sharing personal information. Keeping that information safe is important to us and to the integrity of { -ion-brand-short-name }. Here is how we safeguard your data and protect your identity. + +ion-program-study-intro = When you enroll in this study, you are sharing personal information. Keeping that information safe is important to us and to the integrity of { -ion-brand-short-name }. Here is how we safeguard your data and protect your identity. + + +ion-works-title = How it works: +ion-works-bullet-get-started-title = Get started. +ion-works-bullet-get-started-content = Select the { ion-enrollment-button } button, review and agree to our Privacy Notice, and answer a few (optional) demographic questions. Note that { -ion-brand-short-name } is currently open to participants in the US who are 19 or older. +ion-works-bullet-enroll-title = Enroll in studies. +ion-works-bullet-enroll-content = Share your data with studies run by { -vendor-short-name } and our { -ion-brand-short-name } research partners. You’ll have the opportunity to learn about a study’s goals, the data it collects, and its research team before you enroll. +ion-works-bullet-control-title = Stay in control. +ion-works-bullet-control-content = The { -ion-brand-short-name } icon will appear on the { -brand-product-name } toolbar. Select the icon any time you want to return to this page to update your settings, enroll in a study, or leave a study or the { -ion-brand-short-name } program. + +ion-your-data-title = Your data: why it matters and how we protect it +ion-your-data-summary = { -ion-brand-short-name } puts your data to work for a better internet. Our goal is to better understand topics like internet usage, online privacy, algorithmic bias, discrimination, and misinformation. This in turn can lead to new products that fundamentally change the tech landscape and hand more power and control back to users. + +ion-your-data-bullet-know = You’ll know the information we plan to collect before we collect it. We publish our data collection documentation, so you can confirm this for yourself. Read each privacy notice for detailed information. +ion-your-data-bullet-lengths = We prioritize securing your data and protecting your privacy. +ion-your-data-bullet-leave = You can leave the { -ion-brand-short-name } program at any time, and we’ll stop collecting data when you do. +ion-your-data-learn-more = Learn more about <a data-l10n-name="privacy-policy">managing the data you share</a> with { -ion-brand-short-name }. + +ion-us-only = Sorry, { -ion-brand-short-name } is currently only open to participants in the US. + +ion-enroll-effective-date = Effective September 1, 2020 +ion-enroll-summary = { -ion-brand-short-name } is an experimental initiative led by Mozilla to better understand how our users use and navigate the internet. { -ion-brand-short-name } is available to Firefox users in the United States who are 19 or older. +ion-enroll-demographic = When you join { -ion-brand-short-name }, we’ll ask you to provide optional demographic data. We’ll also collect basic technical and interaction data as long as you’re participating in { -ion-brand-short-name }. Once you’ve enrolled, you’ll have the opportunity to join available studies—each study will have a specific research purpose and unique privacy notice for you to review before you join it. +ion-enroll-privacy-notice = In this Privacy Notice, we detail what data the { -ion-brand-short-name } program collects and discloses, and why. Read each study’s privacy notice for information about how data is collected and handled in that particular study. We also adhere to the <a data-l10n-name="privacy-notice">Mozilla Privacy Policy</a> for how we receive, handle, and share information. +ion-enroll-data-disclosure = To see a full list of the data we collect, click <a data-l10n-name="privacy-policy">here</a>. +ion-enroll-what-we-collect = What Information We Collect: +ion-enroll-collect-demographic = <strong>Demographic data:</strong> We collect optional, self-reported demographic data from { -ion-brand-short-name } participants, including their age, gender, race/ethnicity, education level, household income, and zip code. +ion-enroll-technical-data = <strong>Technical data:</strong> We collect basic information about your device’s operating system. When Firefox sends data to us, your IP address is temporarily collected as part of our server logs. +ion-enroll-interaction-data = <strong>Interaction data:</strong> We collect data about your interactions with Firefox, like number and type of installed Firefox Add-ons and your active browsing session duration. +ion-enroll-location-data = <strong>Location data:</strong> We will use your IP address to approximate your country location, in addition to collecting your self-reported zip code (if you provide it). +ion-enroll-how-we-use = How We Use Your Information: +ion-enroll-r-and-d = We use the information we collect for for <strong>research and development</strong>, including: +ion-enroll-bullet-criteria = To determine which participants meet the criteria to be available to participate in particular research studies +ion-enroll-bullet-representative = To ensure our data sets are representative of the many users of Firefox +ion-enroll-bullet-improve-existing = To improve our existing products and services +ion-enroll-bullet-create = To create and develop new products +ion-enroll-who-we-disclose-to = Who We May Disclose Information To: +ion-enroll-who-we-disclose-bullet-gcp = <strong>Google Cloud Platform (GCP):</strong> We use GCP as our cloud-storage service. Mozilla has contracted with GCP requiring them to handle the data in ways that are approved by us. +ion-enroll-who-we-disclose-bullet-third-party = <strong>Third-party researchers:</strong> As part of being part of the { -ion-brand-short-name } program, we will offer you the ability to join studies. If necessary for the study, we may ask you to share all or some of the data collected under this Privacy Notice with the third party researcher(s) administering a study. Mozilla will contractually obligate the third party researchers to ensure that your data is handled in ways that are approved by us. +ion-enroll-who-we-disclose-bullet-public = <strong>General public:</strong> To advance our <a data-l10n-name="mozilla-manifesto">mission of being open</a>, we may release data sets to the general public. When we do so, we will aggregate the data and remove identifying information, so the data won’t reveal the behaviors or characteristics of individual users. +ion-enroll-data-management = Data Management: +ion-enroll-data-management-learn-more = You can learn more about managing your { -ion-brand-short-name } and individual study data <a data-l10n-name="privacy-policy">here</a>. If you have any other questions regarding our privacy practices, please contact us at <a data-l10n-name="compliance-email">compliance@mozilla.com</a>. diff --git a/browser/components/ion/content/ion.html b/browser/components/ion/content/ion.html new file mode 100644 index 0000000000..be07f5319f --- /dev/null +++ b/browser/components/ion/content/ion.html @@ -0,0 +1,129 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta http-equiv="Content-Security-Policy" content="default-src chrome: blob:; img-src https:; object-src 'none'"> + <meta name="color-scheme" content="light dark"> + <link rel="localization" href="browser/branding/brandings.ftl"> + <link rel="localization" href="branding/brand.ftl"> + <!-- Temporary "en-US"-only l10n strings --> + <link rel="localization" href="preview/ion.ftl"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/ion.css"> + <script src="chrome://browser/content/ion.js"></script> + <link rel="icon" href="chrome://browser/skin/ion.svg"> + <title data-l10n-id="ion"></title> + </head> + + <body> + <div id="locale-notification" data-l10n-id="ion-us-only"></div> + <div id="report-content"> + <header> + <h1 id="title" data-l10n-id="ion-document-title"></h1> + <button id="enrollment-button" class="primary"></button> + </header> + <div id="summary" data-l10n-id="ion-summary"></div> + <details id="details" open> + <summary data-l10n-id="ion-works-title"></summary> + <p><strong data-l10n-id="ion-works-bullet-get-started-title" ></strong> <span data-l10n-id="ion-works-bullet-get-started-content"></span></p> + <p><strong data-l10n-id="ion-works-bullet-enroll-title" ></strong> <span data-l10n-id="ion-works-bullet-enroll-content"></span></p> + <p><strong data-l10n-id="ion-works-bullet-control-title" ></strong> <span data-l10n-id="ion-works-bullet-control-content"></span></p> + </details> + <details id="data" open> + <summary data-l10n-id="ion-your-data-title"></summary> + <p data-l10n-id="ion-your-data-summary"></p> + <ul> + <li data-l10n-id="ion-your-data-bullet-know"></li> + <li data-l10n-id="ion-your-data-bullet-lengths"></li> + <li data-l10n-id="ion-your-data-bullet-leave"></li> + </ul> + </details> + <p data-l10n-id="ion-your-data-learn-more"> + <a data-l10n-name="privacy-policy" class="privacy-policy" href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" target="_blank"></a> + </p> + <h2 id="header-available-studies"></h2> + <div id="available-studies"></div> + <dialog id="join-ion-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="ion-consent-notice"></h3> + <p data-l10n-id="ion-program-consent-intro"></p> + <ul id="join-ion-consent" class="consent-list"> + <p data-l10n-id="ion-enroll-effective-date"></p> + <p data-l10n-id="ion-enroll-summary"></p> + <p data-l10n-id="ion-enroll-demographic"></p> + <p data-l10n-id="ion-enroll-privacy-notice"> + <a data-l10n-name="privacy-notice" class="privacy-notice" href="https://www.mozilla.org/%LOCALE%/privacy/" target="_blank"></a> + </p> + <h2 data-l10n-id="ion-enroll-what-we-collect"></h2> + <p data-l10n-id="ion-enroll-collect-demographic"></p> + <p data-l10n-id="ion-enroll-technical-data"></p> + <p data-l10n-id="ion-enroll-interaction-data"></p> + <p data-l10n-id="ion-enroll-location-data"></p> + <p data-l10n-id="ion-enroll-data-disclosure"> + <a data-l10n-name="privacy-policy" class="privacy-policy" href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" target="_blank"></a> + </p> + <h2 data-l10n-id="ion-enroll-how-we-use"></h2> + <p data-l10n-id="ion-enroll-r-and-d"></p> + <ul> + <li data-l10n-id="ion-enroll-bullet-criteria"></li> + <li data-l10n-id="ion-enroll-bullet-representative"></li> + <li data-l10n-id="ion-enroll-bullet-improve-existing"></li> + <li data-l10n-id="ion-enroll-bullet-create"></li> + </ul> + <h2 data-l10n-id="ion-enroll-who-we-disclose-to"></h2> + <p data-l10n-id="ion-enroll-who-we-disclose-bullet-gcp"></p> + <p data-l10n-id="ion-enroll-who-we-disclose-bullet-third-party"></p> + <p data-l10n-id="ion-enroll-who-we-disclose-bullet-public"> + <a data-l10n-name="mozilla-manifesto" href="https://www.mozilla.org/about/manifesto/" target="_blank"></a> + </p> + <h2 data-l10n-id="ion-enroll-data-management"></h2> + <p data-l10n-id="ion-enroll-data-management-learn-more"> + <a data-l10n-name="privacy-policy" class="privacy-policy" href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" target="_blank"></a> + <a data-l10n-name="compliance-email" href="mailto:compliance@mozilla.com"></a> + </p> + </ul> + <footer> + <button id="join-ion-accept-dialog-button" class="primary" data-l10n-id="ion-accept-participate" ></button> + <button id="join-ion-cancel-dialog-button" data-l10n-id="ion-cancel" ></button> + </footer> + </dialog> + <dialog id="leave-ion-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="leave-ion-consent-title"></h3> + <ul id="leave-ion-consent" class="consent-list"> + <p data-l10n-id="leave-ion-consent-bullet-thanks"></p> + <p data-l10n-id="leave-ion-consent-bullet-manage"> + <a data-l10n-name="privacy-policy" class="privacy-policy" href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" target="_blank"></a> + </p> + </ul> + <footer> + <button id="leave-ion-cancel-dialog-button" class="primary" data-l10n-id="ion-cancel"></button> + <button id="leave-ion-accept-dialog-button" data-l10n-id="ion-accept-leave" ></button> + </footer> + </dialog> + <dialog id="join-study-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="ion-consent-study-notice"></h3> + <p data-l10n-id="ion-program-study-intro"></p> + <ul id="join-study-consent" class="consent-list"></ul> + <footer> + <button id="join-study-accept-dialog-button" class="primary" data-l10n-id="ion-consent-study-join"></button> + <button id="join-study-cancel-dialog-button" data-l10n-id="ion-cancel" ></button> + </footer> + </dialog> + <dialog id="leave-study-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="leave-study-consent-title"></h3> + <ul id="leave-study-consent" class="consent-list"> + <p data-l10n-id="leave-study-consent-bullet-manage"> + <a data-l10n-name="privacy-policy" class="privacy-policy" href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" target="_blank"></a> + </p> + </ul> + <footer> + <button id="leave-study-cancel-dialog-button" class="primary" data-l10n-id="ion-cancel" ></button> + <button id="leave-study-accept-dialog-button" data-l10n-id="ion-consent-study-leave"></button> + </footer> + </dialog> + </div> + </body> +</html> diff --git a/browser/components/ion/content/ion.js b/browser/components/ion/content/ion.js new file mode 100644 index 0000000000..330bf29405 --- /dev/null +++ b/browser/components/ion/content/ion.js @@ -0,0 +1,792 @@ +/* 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/. */ + +/** + * Control panel for the Ion project, formerly known as Pioneer. + * This lives in `about:ion` and provides a UI for users to un/enroll in the + * overall program, and to un/enroll from individual studies. + * + * NOTE - prefs and Telemetry both still mention Pioneer for backwards-compatibility, + * this may change in the future. + */ + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +const { RemoteSettings } = ChromeUtils.import( + "resource://services-settings/remote-settings.js" +); + +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); + +let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils +); + +const PREF_ION_ID = "toolkit.telemetry.pioneerId"; +const PREF_ION_NEW_STUDIES_AVAILABLE = + "toolkit.telemetry.pioneer-new-studies-available"; +const PREF_ION_COMPLETED_STUDIES = + "toolkit.telemetry.pioneer-completed-studies"; + +/** + * Remote Settings keys for general content, and available studies. + */ +const CONTENT_COLLECTION_KEY = "pioneer-content-v2"; +const STUDY_ADDON_COLLECTION_KEY = "pioneer-study-addons-v2"; + +const STUDY_LEAVE_REASONS = { + USER_ABANDONED: "user-abandoned", + STUDY_ENDED: "study-ended", +}; + +const PREF_TEST_CACHED_CONTENT = "toolkit.pioneer.testCachedContent"; +const PREF_TEST_CACHED_ADDONS = "toolkit.pioneer.testCachedAddons"; +const PREF_TEST_ADDONS = "toolkit.pioneer.testAddons"; + +/** + * Use the in-tree HTML Sanitizer to ensure that HTML from remote-settings is safe to use. + * Note that RS does use content-signing, we're doing this extra step as an in-depth security measure. + * + * @param {string} htmlString - unsanitized HTML (content-signed by remote-settings) + * @returns {DocumentFragment} - sanitized DocumentFragment + */ +function sanitizeHtml(htmlString) { + const content = document.createElement("div"); + const contentFragment = parserUtils.parseFragment( + htmlString, + Ci.nsIParserUtils.SanitizerDropForms | + Ci.nsIParserUtils.SanitizerAllowStyle | + Ci.nsIParserUtils.SanitizerLogRemovals, + false, + Services.io.newURI("about:ion"), + content + ); + + return contentFragment; +} + +function showEnrollmentStatus() { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + const enrollmentButton = document.getElementById("enrollment-button"); + + document.l10n.setAttributes( + enrollmentButton, + `ion-${ionId ? "un" : ""}enrollment-button` + ); + enrollmentButton.classList.toggle("primary", !ionId); + + // collapse content above the fold if enrolled, otherwise open it. + for (const section of ["details", "data"]) { + const details = document.getElementById(section); + if (ionId) { + details.removeAttribute("open"); + } else { + details.setAttribute("open", true); + } + } +} + +function toggleContentBasedOnLocale() { + const requestedLocale = Services.locale.requestedLocale; + if (requestedLocale !== "en-US") { + const localeNotificationBar = document.getElementById( + "locale-notification" + ); + localeNotificationBar.style.display = "block"; + + const reportContent = document.getElementById("report-content"); + reportContent.style.display = "none"; + } +} + +async function toggleEnrolled(studyAddonId, cachedAddons) { + let addon; + let install; + + const cachedAddon = cachedAddons.find(a => a.addon_id == studyAddonId); + + if (Cu.isInAutomation) { + install = { + install: async () => { + let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + testAddons = JSON.parse(testAddons); + + testAddons.push(studyAddonId); + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify(testAddons) + ); + }, + }; + + let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + testAddons = JSON.parse(testAddons); + + for (const testAddon of testAddons) { + if (testAddon == studyAddonId) { + addon = {}; + addon.uninstall = () => { + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify(testAddons.filter(a => a.id != testAddon.id)) + ); + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + install = await AddonManager.getInstallForURL(cachedAddon.sourceURI.spec); + } + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const study = document.querySelector(`.card[id="${cachedAddon.addon_id}"`); + const joinBtn = study.querySelector(".join-button"); + + if (addon) { + joinBtn.disabled = true; + await addon.uninstall(); + await sendDeletionPing(studyAddonId); + + document.l10n.setAttributes(joinBtn, "ion-join-study"); + joinBtn.disabled = false; + + // Record that the user abandoned this study, since it may not be re-join-able. + if (completedStudies) { + const studies = JSON.parse(completedStudies); + studies[studyAddonId] = STUDY_LEAVE_REASONS.USER_ABANDONED; + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + } else { + // Check if this study is re-join-able before enrollment. + const studies = JSON.parse(completedStudies); + if (studyAddonId in studies) { + if ( + "canRejoin" in cachedAddons[studyAddonId] && + cachedAddons[studyAddonId].canRejoin === false + ) { + console.error( + `Cannot rejoin ended study ${studyAddonId}, reason: ${studies[studyAddonId]}` + ); + return; + } + } + joinBtn.disabled = true; + await install.install(); + document.l10n.setAttributes(joinBtn, "ion-leave-study"); + joinBtn.disabled = false; + + // Send an enrollment ping for this study. Note that this could be sent again + // if we are re-joining. + await sendEnrollmentPing(studyAddonId); + } + + await updateStudy(cachedAddon.addon_id); +} + +async function showAvailableStudies(cachedAddons) { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + const defaultAddons = cachedAddons.filter(a => a.isDefault); + if (ionId) { + for (const defaultAddon of defaultAddons) { + let addon; + let install; + if (Cu.isInAutomation) { + install = { + install: async () => { + if ( + defaultAddon.addon_id == "ion-v2-bad-default-example@mozilla.org" + ) { + throw new Error("Bad test default add-on"); + } + }, + }; + } else { + addon = await AddonManager.getAddonByID(defaultAddon.addon_id); + install = await AddonManager.getInstallForURL( + defaultAddon.sourceURI.spec + ); + } + + if (!addon) { + // Any default add-ons are required, try to reinstall. + await install.install(); + } + } + } + + const studyAddons = cachedAddons.filter(a => !a.isDefault); + for (const cachedAddon of studyAddons) { + if (!cachedAddon) { + console.error( + `about:ion - Study addon ID not found in cache: ${studyAddonId}` + ); + return; + } + + const studyAddonId = cachedAddon.addon_id; + + const study = document.createElement("div"); + study.setAttribute("id", studyAddonId); + study.setAttribute("class", "card card-no-hover"); + + if (cachedAddon.icons && 32 in cachedAddon.icons) { + const iconName = document.createElement("img"); + iconName.setAttribute("class", "card-icon"); + iconName.setAttribute("src", cachedAddon.icons[32]); + study.appendChild(iconName); + } + + const studyBody = document.createElement("div"); + studyBody.classList.add("card-body"); + study.appendChild(studyBody); + + const studyName = document.createElement("h3"); + studyName.setAttribute("class", "card-name"); + studyName.textContent = cachedAddon.name; + studyBody.appendChild(studyName); + + const studyAuthor = document.createElement("span"); + studyAuthor.setAttribute("class", "card-author"); + studyAuthor.textContent = cachedAddon.authors.name; + studyBody.appendChild(studyAuthor); + + const actions = document.createElement("div"); + actions.classList.add("card-actions"); + study.appendChild(actions); + + const joinBtn = document.createElement("button"); + joinBtn.setAttribute("id", `${studyAddonId}-join-button`); + joinBtn.classList.add("primary"); + joinBtn.classList.add("join-button"); + document.l10n.setAttributes(joinBtn, "ion-join-study"); + + joinBtn.addEventListener("click", async () => { + let addon; + if (Cu.isInAutomation) { + const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + for (const testAddon of JSON.parse(testAddons)) { + if (testAddon == studyAddonId) { + addon = {}; + addon.uninstall = () => { + Services.prefs.setStringPref(PREF_TEST_ADDONS, "[]"); + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + } + let joinOrLeave = addon ? "leave" : "join"; + let dialog = document.getElementById( + `${joinOrLeave}-study-consent-dialog` + ); + dialog.setAttribute("addon-id", cachedAddon.addon_id); + const consentText = dialog.querySelector( + `[id=${joinOrLeave}-study-consent]` + ); + + // Clears out any existing children with a single #text node + consentText.textContent = ""; + + const contentFragment = sanitizeHtml( + cachedAddon[`${joinOrLeave}StudyConsent`] + ); + consentText.appendChild(contentFragment); + + dialog.showModal(); + dialog.scrollTop = 0; + + const openEvent = new Event("open"); + dialog.dispatchEvent(openEvent); + }); + actions.appendChild(joinBtn); + + const studyDesc = document.createElement("div"); + studyDesc.setAttribute("class", "card-description"); + + const contentFragment = sanitizeHtml(cachedAddon.description); + studyDesc.appendChild(contentFragment); + + study.appendChild(studyDesc); + + const studyDataCollected = document.createElement("div"); + studyDataCollected.setAttribute("class", "card-data-collected"); + study.appendChild(studyDataCollected); + + const dataCollectionDetailsHeader = document.createElement("p"); + dataCollectionDetailsHeader.textContent = "This study will collect:"; + studyDataCollected.appendChild(dataCollectionDetailsHeader); + + const dataCollectionDetails = document.createElement("ul"); + for (const dataCollectionDetail of cachedAddon.dataCollectionDetails) { + const detailsBullet = document.createElement("li"); + detailsBullet.textContent = dataCollectionDetail; + dataCollectionDetails.append(detailsBullet); + } + studyDataCollected.appendChild(dataCollectionDetails); + + const availableStudies = document.getElementById("available-studies"); + availableStudies.appendChild(study); + + await updateStudy(studyAddonId); + } + + const availableStudies = document.getElementById("header-available-studies"); + document.l10n.setAttributes(availableStudies, "ion-current-studies"); +} + +async function updateStudy(studyAddonId) { + let addon; + if (Cu.isInAutomation) { + const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + for (const testAddon of JSON.parse(testAddons)) { + if (testAddon == studyAddonId) { + addon = { + uninstall() {}, + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + } + + const study = document.querySelector(`.card[id="${studyAddonId}"`); + + const joinBtn = study.querySelector(".join-button"); + + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const studies = JSON.parse(completedStudies); + if (studyAddonId in studies) { + study.style.opacity = 0.5; + joinBtn.disabled = true; + document.l10n.setAttributes(joinBtn, "ion-ended-study"); + return; + } + + if (ionId) { + study.style.opacity = 1; + joinBtn.disabled = false; + + if (addon) { + document.l10n.setAttributes(joinBtn, "ion-leave-study"); + } else { + document.l10n.setAttributes(joinBtn, "ion-join-study"); + } + } else { + document.l10n.setAttributes(joinBtn, "ion-study-prompt"); + study.style.opacity = 0.5; + joinBtn.disabled = true; + } +} + +// equivalent to what we use for Telemetry IDs +// https://searchfox.org/mozilla-central/rev/9193635dca8cfdcb68f114306194ffc860456044/toolkit/components/telemetry/app/TelemetryUtils.jsm#222 +function generateUUID() { + let str = Services.uuid.generateUUID().toString(); + return str.substring(1, str.length - 1); +} + +async function setup(cachedAddons) { + document + .getElementById("enrollment-button") + .addEventListener("click", async () => { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + if (ionId) { + let dialog = document.getElementById("leave-ion-consent-dialog"); + dialog.showModal(); + dialog.scrollTop = 0; + } else { + let dialog = document.getElementById("join-ion-consent-dialog"); + dialog.showModal(); + dialog.scrollTop = 0; + } + }); + + document + .getElementById("join-ion-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("join-ion-consent-dialog").close() + ); + document + .getElementById("leave-ion-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("leave-ion-consent-dialog").close() + ); + document + .getElementById("join-study-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("join-study-consent-dialog").close() + ); + document + .getElementById("leave-study-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("leave-study-consent-dialog").close() + ); + + document + .getElementById("join-ion-accept-dialog-button") + .addEventListener("click", async event => { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + if (!ionId) { + let uuid = generateUUID(); + Services.prefs.setStringPref(PREF_ION_ID, uuid); + for (const cachedAddon of cachedAddons) { + if (cachedAddon.isDefault) { + let install; + if (Cu.isInAutomation) { + install = { + install: async () => { + if ( + cachedAddon.addon_id == + "ion-v2-bad-default-example@mozilla.org" + ) { + throw new Error("Bad test default add-on"); + } + }, + }; + } else { + install = await AddonManager.getInstallForURL( + cachedAddon.sourceURI.spec + ); + } + + try { + await install.install(); + } catch (ex) { + // No need to throw here, we'll try again before letting users enroll in any studies. + console.error( + `Could not install default add-on ${cachedAddon.addon_id}` + ); + const availableStudies = document.getElementById( + "available-studies" + ); + document.l10n.setAttributes( + availableStudies, + "ion-no-current-studies" + ); + } + } + const study = document.getElementById(cachedAddon.addon_id); + if (study) { + await updateStudy(cachedAddon.addon_id); + } + } + document.querySelector("dialog").close(); + } + // A this point we should have a valid ion id, so we should be able to send + // the enrollment ping. + await sendEnrollmentPing(); + + showEnrollmentStatus(); + }); + + document + .getElementById("leave-ion-accept-dialog-button") + .addEventListener("click", async event => { + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + const studies = JSON.parse(completedStudies); + + // Send a deletion ping for all completed studies the user has been a part of. + for (const studyAddonId in studies) { + await sendDeletionPing(studyAddonId); + } + + Services.prefs.clearUserPref(PREF_ION_COMPLETED_STUDIES); + + for (const cachedAddon of cachedAddons) { + // Record any studies that have been marked as concluded on the server, in case they re-enroll. + if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) { + studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED; + + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + + let addon; + if (Cu.isInAutomation) { + addon = {}; + addon.id = cachedAddon.addon_id; + addon.uninstall = () => { + let testAddons = Services.prefs.getStringPref( + PREF_TEST_ADDONS, + "[]" + ); + testAddons = JSON.parse(testAddons); + + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify( + testAddons.filter(a => a.id != cachedAddon.addon_id) + ) + ); + }; + } else { + addon = await AddonManager.getAddonByID(cachedAddon.addon_id); + } + if (addon) { + await sendDeletionPing(addon.id); + await addon.uninstall(); + } + } + + Services.prefs.clearUserPref(PREF_ION_ID); + for (const cachedAddon of cachedAddons) { + const study = document.getElementById(cachedAddon.addon_id); + if (study) { + await updateStudy(cachedAddon.addon_id); + } + } + + document.getElementById("leave-ion-consent-dialog").close(); + showEnrollmentStatus(); + }); + + document + .getElementById("join-study-accept-dialog-button") + .addEventListener("click", async event => { + const dialog = document.getElementById("join-study-consent-dialog"); + const studyAddonId = dialog.getAttribute("addon-id"); + toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); + }); + + document + .getElementById("leave-study-accept-dialog-button") + .addEventListener("click", async event => { + const dialog = document.getElementById("leave-study-consent-dialog"); + const studyAddonId = dialog.getAttribute("addon-id"); + await toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); + }); + + const onAddonEvent = async addon => { + for (const cachedAddon of cachedAddons) { + if (cachedAddon.addon_id == addon.id) { + await updateStudy(addon.id); + } + } + }; + + const addonsListener = { + onEnabled: onAddonEvent, + onDisabled: onAddonEvent, + onInstalled: onAddonEvent, + onUninstalled: onAddonEvent, + }; + AddonManager.addAddonListener(addonsListener); + + window.addEventListener("unload", event => { + AddonManager.removeAddonListener(addonsListener); + }); +} + +function removeBadge() { + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, false); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + const badge = win.document + .getElementById("ion-button") + .querySelector(".toolbarbutton-badge"); + badge.classList.remove("feature-callout"); + } +} + +// Updates Ion HTML page contents from RemoteSettings. +function updateContents(contents) { + for (const section of [ + "title", + "summary", + "details", + "data", + "joinIonConsent", + "leaveIonConsent", + ]) { + if (contents && section in contents) { + // Generate a corresponding dom-id style ID for a camel-case domId style JS attribute. + // Dynamically set the tag type based on which section is getting updated. + const domId = section + .split(/(?=[A-Z])/) + .join("-") + .toLowerCase(); + // Clears out any existing children with a single #text node. + document.getElementById(domId).textContent = ""; + + const contentFragment = sanitizeHtml(contents[section]); + document.getElementById(domId).appendChild(contentFragment); + } + } +} + +document.addEventListener("DOMContentLoaded", async domEvent => { + toggleContentBasedOnLocale(); + + showEnrollmentStatus(); + + document.addEventListener("focus", removeBadge); + removeBadge(); + + const privacyPolicyLinks = document.querySelectorAll( + ".privacy-policy,.privacy-notice" + ); + for (const privacyPolicyLink of privacyPolicyLinks) { + const privacyPolicyFormattedLink = Services.urlFormatter.formatURL( + privacyPolicyLink.href + ); + privacyPolicyLink.href = privacyPolicyFormattedLink; + } + + let cachedContent; + let cachedAddons; + if (Cu.isInAutomation) { + let testCachedAddons = Services.prefs.getStringPref( + PREF_TEST_CACHED_ADDONS, + null + ); + if (testCachedAddons) { + cachedAddons = JSON.parse(testCachedAddons); + } + + let testCachedContent = Services.prefs.getStringPref( + PREF_TEST_CACHED_CONTENT, + null + ); + if (testCachedContent) { + cachedContent = JSON.parse(testCachedContent); + } + } else { + cachedContent = await RemoteSettings(CONTENT_COLLECTION_KEY).get(); + cachedAddons = await RemoteSettings(STUDY_ADDON_COLLECTION_KEY).get(); + } + + // Replace existing contents immediately on page load. + for (const contents of cachedContent) { + updateContents(contents); + } + + for (const cachedAddon of cachedAddons) { + // Record any studies that have been marked as concluded on the server. + if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) { + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + const studies = JSON.parse(completedStudies); + studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED; + + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + } + + await setup(cachedAddons); + + try { + await showAvailableStudies(cachedAddons); + } catch (ex) { + // No need to throw here, we'll try again before letting users enroll in any studies. + console.error(`Could not show available studies`, ex); + } +}); + +async function sendDeletionPing(studyAddonId) { + const type = "pioneer-study"; + + const options = { + studyName: studyAddonId, + addPioneerId: true, + useEncryption: true, + // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry + // pipeline requires that pings are shaped this way so they are routed to the correct environment. + // + // At the moment, the public key used here isn't important but we do need to use *something*. + encryptionKeyId: "discarded", + publicKey: { + crv: "P-256", + kty: "EC", + x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk", + y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE", + }, + schemaName: "deletion-request", + schemaVersion: 1, + // The schema namespace needs to be the study addon id, as we + // want to route the ping to the specific study table. + schemaNamespace: studyAddonId, + }; + + const payload = { + encryptedData: "", + }; + + await TelemetryController.submitExternalPing(type, payload, options); +} + +/** + * Sends a Pioneer enrollment ping. + * + * The `creationDate` provided by the telemetry APIs will be used as the timestamp for + * considering the user enrolled in pioneer and/or the study. + * + * @param [studyAddonid=undefined] - optional study id. It's sent in the ping, if present, + * to signal that user enroled in the study. + */ +async function sendEnrollmentPing(studyAddonId) { + let options = { + studyName: "pioneer-meta", + addPioneerId: true, + useEncryption: true, + // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry + // pipeline requires that pings are shaped this way so they are routed to the correct environment. + // + // At the moment, the public key used here isn't important but we do need to use *something*. + encryptionKeyId: "discarded", + publicKey: { + crv: "P-256", + kty: "EC", + x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk", + y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE", + }, + schemaName: "pioneer-enrollment", + schemaVersion: 1, + // Note that the schema namespace directly informs how data is segregated after ingestion. + // If this is an enrollment ping for the pioneer program (in contrast to the enrollment to + // a specific study), use a meta namespace. + schemaNamespace: "pioneer-meta", + }; + + // If we were provided with a study id, then this is an enrollment to a study. + // Send the id alongside with the data and change the schema namespace to simplify + // the work on the ingestion pipeline. + if (typeof studyAddonId != "undefined") { + options.studyName = studyAddonId; + // The schema namespace needs to be the study addon id, as we + // want to route the ping to the specific study table. + options.schemaNamespace = studyAddonId; + } + + await TelemetryController.submitExternalPing("pioneer-study", {}, options); +} diff --git a/browser/components/ion/jar.mn b/browser/components/ion/jar.mn new file mode 100644 index 0000000000..b7582addd4 --- /dev/null +++ b/browser/components/ion/jar.mn @@ -0,0 +1,8 @@ +# 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/. + +browser.jar: + content/browser/ion.html (content/ion.html) + content/browser/ion.css (content/ion.css) + content/browser/ion.js (content/ion.js) diff --git a/browser/components/ion/moz.build b/browser/components/ion/moz.build new file mode 100644 index 0000000000..24736258a6 --- /dev/null +++ b/browser/components/ion/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "General") + +TESTING_JS_MODULES += [ + "schemas/IonContentSchema.json", + "schemas/IonStudyAddonsSchema.json", +] diff --git a/browser/components/ion/schemas/IonContentSchema.json b/browser/components/ion/schemas/IonContentSchema.json new file mode 100644 index 0000000000..96bb4e7b81 --- /dev/null +++ b/browser/components/ion/schemas/IonContentSchema.json @@ -0,0 +1,39 @@ +{ + "definitions": {}, + "title": "Root", + "type": "object", + "required": [ + "title", + "summary", + "details", + "joinIonConsent", + "leaveIonConsent" + ], + "properties": { + "title": { + "$id": "#root/title", + "title": "Title", + "type": "string" + }, + "summary": { + "$id": "#root/summary", + "title": "Summary", + "type": "string" + }, + "details": { + "$id": "#root/details", + "title": "Details", + "type": "string" + }, + "joinIonConsent": { + "$id": "#root/joinIonConsent", + "title": "JoinIonconsent", + "type": "string" + }, + "leaveIonConsent": { + "$id": "#root/leaveIonConsent", + "title": "LeaveIonconsent", + "type": "string" + } + } +} diff --git a/browser/components/ion/schemas/IonStudyAddonsSchema.json b/browser/components/ion/schemas/IonStudyAddonsSchema.json new file mode 100644 index 0000000000..116b0dc1ae --- /dev/null +++ b/browser/components/ion/schemas/IonStudyAddonsSchema.json @@ -0,0 +1,159 @@ +{ + "definitions": {}, + "title": "Root", + "type": "object", + "required": [ + "addon_id", + "icons", + "name", + "version", + "sourceURI", + "description", + "privacyPolicy", + "studyType", + "authors", + "dataCollectionDetails", + "moreInfo", + "isDefault", + "studyEnded", + "joinStudyConsent", + "leaveStudyConsent" + ], + "properties": { + "addon_id": { + "$id": "#root/addon_id", + "title": "Addon_id", + "type": "string" + }, + "icons": { + "$id": "#root/icons", + "title": "Icons", + "type": "object", + "required": ["32", "64", "128"], + "properties": { + "32": { + "$id": "#root/icons/32", + "title": "32", + "type": "string" + }, + "64": { + "$id": "#root/icons/64", + "title": "64", + "type": "string" + }, + "128": { + "$id": "#root/icons/128", + "title": "128", + "type": "string" + } + } + }, + "name": { + "$id": "#root/name", + "title": "Name", + "type": "string" + }, + "version": { + "$id": "#root/version", + "title": "Version", + "type": "string" + }, + "sourceURI": { + "$id": "#root/sourceURI", + "title": "Sourceuri", + "type": "object", + "required": ["spec"], + "properties": { + "spec": { + "$id": "#root/sourceURI/spec", + "title": "Spec", + "type": "string" + } + } + }, + "description": { + "$id": "#root/description", + "title": "Description", + "type": "string" + }, + "privacyPolicy": { + "$id": "#root/privacyPolicy", + "title": "Privacypolicy", + "type": "object", + "required": ["spec"], + "properties": { + "spec": { + "$id": "#root/privacyPolicy/spec", + "title": "Spec", + "type": "string" + } + } + }, + "studyType": { + "$id": "#root/studyType", + "title": "Studytype", + "type": "string" + }, + "authors": { + "$id": "#root/authors", + "title": "Authors", + "type": "object", + "required": ["name", "url"], + "properties": { + "name": { + "$id": "#root/authors/name", + "title": "Name", + "type": "string" + }, + "url": { + "$id": "#root/authors/url", + "title": "Url", + "type": "string" + } + } + }, + "dataCollectionDetails": { + "$id": "#root/dataCollectionDetails", + "title": "Datacollectiondetails", + "type": "array", + "items": { + "$id": "#root/dataCollectionDetails/items", + "title": "Items", + "type": "string" + } + }, + "moreInfo": { + "$id": "#root/moreInfo", + "title": "Moreinfo", + "type": "object", + "required": ["spec"], + "properties": { + "spec": { + "$id": "#root/moreInfo/spec", + "title": "Spec", + "type": "string" + } + } + }, + "isDefault": { + "$id": "#root/isDefault", + "title": "Isdefault", + "type": "boolean" + }, + "studyEnded": { + "$id": "#root/studyEnded", + "title": "Studyended", + "type": "boolean" + }, + "joinStudyConsent": { + "$id": "#root/joinStudyConsent", + "title": "Joinstudyconsent", + "type": "string" + }, + "leaveStudyConsent": { + "$id": "#root/leaveStudyConsent", + "title": "Leavestudyconsent", + "type": "string" + } + } +} diff --git a/browser/components/ion/test/browser/browser.ini b/browser/components/ion/test/browser/browser.ini new file mode 100644 index 0000000000..383319b953 --- /dev/null +++ b/browser/components/ion/test/browser/browser.ini @@ -0,0 +1,3 @@ +[DEFAULT] + +[browser_ion_ui.js] diff --git a/browser/components/ion/test/browser/browser_ion_ui.js b/browser/components/ion/test/browser/browser_ion_ui.js new file mode 100644 index 0000000000..c5538a2861 --- /dev/null +++ b/browser/components/ion/test/browser/browser_ion_ui.js @@ -0,0 +1,1133 @@ +/* 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"; + +const { JsonSchema } = ChromeUtils.importESModule( + "resource://gre/modules/JsonSchema.sys.mjs" +); + +const { TelemetryArchive } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryArchive.sys.mjs" +); + +const { TelemetryStorage } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryStorage.sys.mjs" +); + +const ORIG_AVAILABLE_LOCALES = Services.locale.availableLocales; +const ORIG_REQUESTED_LOCALES = Services.locale.requestedLocales; + +const PREF_ION_ID = "toolkit.telemetry.pioneerId"; +const PREF_ION_NEW_STUDIES_AVAILABLE = + "toolkit.telemetry.pioneer-new-studies-available"; +const PREF_ION_COMPLETED_STUDIES = + "toolkit.telemetry.pioneer-completed-studies"; + +const PREF_TEST_CACHED_CONTENT = "toolkit.pioneer.testCachedContent"; +const PREF_TEST_CACHED_ADDONS = "toolkit.pioneer.testCachedAddons"; +const PREF_TEST_ADDONS = "toolkit.pioneer.testAddons"; + +const CACHED_CONTENT = [ + { + title: "<b>test title</b><p>test title line 2</p>", + summary: "<p>test summary</p>test summary line 2", + details: + "<ol><li>test details</li><li>test details line 2</li><li>test details line</li></ol>", + data: "<b>test data</b>", + joinIonConsent: "<p>test join consent</p><p>join consent line 2</p>", + leaveIonConsent: + "<p>test leave consent</p><p>test leave consent line 2</p>", + }, +]; + +const CACHED_ADDONS = [ + { + addon_id: "ion-v2-example@mozilla.org", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Demo Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Ion Developers", + url: "https://addons.mozilla.org/en-US/firefox/user/6510522/", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: true, + joinStudyConsent: "<b>test123</b>", + leaveStudyConsent: `<a href="test345">test345</a>`, + }, + { + addon_id: "ion-v2-default-example@mozilla.org", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Demo Default Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Ion Developers", + url: "https://addons.mozilla.org/en-US/firefox/user/6510522/", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: true, + studyEnded: false, + joinStudyConsent: "test456", + leaveStudyConsent: "test789", + }, + { + addon_id: "study@partner", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Study Partners", + url: "http://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test012", + leaveStudyConsent: "test345", + }, + { + addon_id: "second-study@partner", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Second Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Second Study Partners", + url: "https://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test678", + leaveStudyConsent: "test901", + }, +]; + +const CACHED_ADDONS_BAD_DEFAULT = [ + { + addon_id: "ion-v2-bad-default-example@mozilla.org", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Demo Default Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Ion Developers", + url: "https://addons.mozilla.org/en-US/firefox/user/6510522/", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: true, + studyEnded: false, + joinStudyConsent: "test456", + leaveStudyConsent: "test789", + }, + { + addon_id: "study@partner", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Study Partners", + url: "http://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test012", + leaveStudyConsent: "test345", + }, + { + addon_id: "second-study@partner", + icons: { + "32": + "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + "64": + "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + "128": + "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Second Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Second Study Partners", + url: "https://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test678", + leaveStudyConsent: "test901", + }, +]; + +const TEST_ADDONS = [ + { id: "ion-v2-example@ion.mozilla.org" }, + { id: "ion-v2-default-example@mozilla.org" }, + { id: "study@partner" }, + { id: "second-study@parnter" }, +]; + +const setupLocale = async locale => { + Services.locale.availableLocales = [locale]; + Services.locale.requestedLocales = [locale]; +}; + +const clearLocale = async () => { + Services.locale.availableLocales = ORIG_AVAILABLE_LOCALES; + Services.locale.requestedLocales = ORIG_REQUESTED_LOCALES; +}; + +add_task(async function testMockSchema() { + for (const [schemaName, values] of [ + ["IonContentSchema", CACHED_CONTENT], + ["IonStudyAddonsSchema", CACHED_ADDONS], + ]) { + const response = await fetch( + `resource://testing-common/${schemaName}.json` + ); + + const schema = await response.json(); + if (!schema) { + throw new Error(`Failed to load ${schemaName}`); + } + + const validator = new JsonSchema.Validator(schema, { shortCircuit: false }); + + for (const entry of values) { + const result = validator.validate(entry); + if (!result.valid) { + throw new Error(JSON.stringify(result.errors)); + } + } + } +}); + +add_task(async function testBadDefaultAddon() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify(CACHED_ADDONS_BAD_DEFAULT); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(beforePref === null, "before enrollment, Ion pref is null."); + const enrollmentButton = content.document.getElementById( + "enrollment-button" + ); + enrollmentButton.click(); + + const dialog = content.document.getElementById("join-ion-consent-dialog"); + ok(dialog.open, "after clicking enrollment, consent dialog is open."); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + enrollmentButton.getBoundingClientRect(); + enrollmentButton.click(); + ok(dialog.open, "after retrying enrollment, consent dialog is open."); + + const acceptDialogButton = content.document.getElementById( + "join-ion-accept-dialog-button" + ); + // Wait for the enrollment button to change its label to "leave", meaning + // that the policy was accepted. + let promiseDialogAccepted = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + enrollmentButton + ); + acceptDialogButton.click(); + + const ionEnrolled = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(ionEnrolled, "after enrollment, Ion pref is set."); + + await promiseDialogAccepted; + ok( + document.l10n.getAttributes(enrollmentButton).id == + "ion-unenrollment-button", + "After Ion enrollment, join button is now leave button" + ); + + const availableStudies = content.document.getElementById( + "available-studies" + ); + ok( + document.l10n.getAttributes(availableStudies).id == + "ion-no-current-studies", + "No studies are available if default add-on install fails." + ); + } + ); +}); + +add_task(async function testAboutPage() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify(CACHED_ADDONS); + + // Clear any previously generated archived ping before moving on + // with this test. + await TelemetryStorage.runCleanPingArchiveTask(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(beforePref === null, "before enrollment, Ion pref is null."); + + const beforeToolbarButton = document.getElementById("ion-button"); + ok( + beforeToolbarButton.hidden, + "before enrollment, Ion toolbar button is hidden." + ); + + const enrollmentButton = content.document.getElementById( + "enrollment-button" + ); + + for (const section of ["details", "data"]) { + ok( + content.document.getElementById(section).open === true, + "before enrollment, dialog sections are open." + ); + } + + enrollmentButton.click(); + + const dialog = content.document.getElementById("join-ion-consent-dialog"); + ok(dialog.open, "after clicking enrollment, consent dialog is open."); + + const cancelDialogButton = content.document.getElementById( + "join-ion-cancel-dialog-button" + ); + cancelDialogButton.click(); + + ok( + !dialog.open, + "after cancelling enrollment, consent dialog is closed." + ); + + const canceledEnrollment = Services.prefs.getStringPref( + PREF_ION_ID, + null + ); + + ok( + !canceledEnrollment, + "after cancelling enrollment, Ion is not enrolled." + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + enrollmentButton.getBoundingClientRect(); + enrollmentButton.click(); + ok(dialog.open, "after retrying enrollment, consent dialog is open."); + + const acceptDialogButton = content.document.getElementById( + "join-ion-accept-dialog-button" + ); + // Wait for the enrollment button to change its label to "leave", meaning + // that the policy was accepted. + let promiseDialogAccepted = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + enrollmentButton + ); + acceptDialogButton.click(); + + const ionEnrolled = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(ionEnrolled, "after enrollment, Ion pref is set."); + + await promiseDialogAccepted; + ok( + document.l10n.getAttributes(enrollmentButton).id == + "ion-unenrollment-button", + "After Ion enrollment, join button is now leave button" + ); + + const enrolledToolbarButton = document.getElementById("ion-button"); + ok( + !enrolledToolbarButton.hidden, + "after enrollment, Ion toolbar button is not hidden." + ); + + for (const section of ["details", "data"]) { + ok( + content.document.getElementById(section).open === false, + "after enrollment, dialog sections are closed." + ); + } + + for (const cachedAddon of CACHED_ADDONS) { + const addonId = cachedAddon.addon_id; + const joinButton = content.document.getElementById( + `${addonId}-join-button` + ); + + if (cachedAddon.isDefault) { + ok(!joinButton, "There is no join button for default study."); + continue; + } + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const studies = JSON.parse(completedStudies); + + if (cachedAddon.studyEnded || Object.keys(studies).includes(addonId)) { + ok( + joinButton.disabled, + "Join button is disabled, study has already ended." + ); + continue; + } + + ok( + !joinButton.disabled, + "Before study enrollment, join button is enabled." + ); + + const studyCancelButton = content.document.getElementById( + "join-study-cancel-dialog-button" + ); + + const joinDialogOpen = new Promise(resolve => { + content.document + .getElementById("join-study-consent-dialog") + .addEventListener("open", () => { + // Run resolve() on the next tick. + setTimeout(() => resolve(), 0); + }); + }); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + // + // Note: this initial call is required because we're cycling through + // addons. So while in the first iteration this would work, it could + // fail on the second or third. + joinButton.getBoundingClientRect(); + joinButton.click(); + + await joinDialogOpen; + + ok( + content.document.getElementById("join-study-consent").innerHTML == + `${cachedAddon.joinStudyConsent}`, + "Join consent text matches remote settings data." + ); + + studyCancelButton.click(); + + ok( + !joinButton.disabled, + "After canceling study enrollment, join button is enabled." + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + const studyAcceptButton = content.document.getElementById( + "join-study-accept-dialog-button" + ); + + // Wait for the "Join Button" to change to a "leave button". + let promiseJoinTurnsToLeave = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + joinButton + ); + studyAcceptButton.click(); + await promiseJoinTurnsToLeave; + + ok( + document.l10n.getAttributes(joinButton).id == "ion-leave-study", + "After study enrollment, join button is now leave button" + ); + + ok( + !joinButton.disabled, + "After study enrollment, leave button is enabled." + ); + + const leaveStudyCancelButton = content.document.getElementById( + "leave-study-cancel-dialog-button" + ); + + const leaveDialogOpen = new Promise(resolve => { + content.document + .getElementById("leave-study-consent-dialog") + .addEventListener("open", () => { + // Run resolve() on the next tick. + setTimeout(() => resolve(), 0); + }); + }); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + await leaveDialogOpen; + + ok( + content.document.getElementById("leave-study-consent").innerHTML == + `${cachedAddon.leaveStudyConsent}`, + "Leave consent text matches remote settings data." + ); + + leaveStudyCancelButton.click(); + + ok( + !joinButton.disabled, + "After canceling study leave, leave/join button is enabled." + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + const acceptStudyCancelButton = content.document.getElementById( + "leave-study-accept-dialog-button" + ); + + let promiseJoinButtonDisabled = BrowserTestUtils.waitForAttribute( + "disabled", + joinButton + ); + acceptStudyCancelButton.click(); + await promiseJoinButtonDisabled; + + ok( + joinButton.disabled, + "After leaving study, join button is disabled." + ); + + ok( + Services.prefs.getStringPref(PREF_TEST_ADDONS, null) == "[]", + "Correct add-on was uninstalled" + ); + } + + enrollmentButton.click(); + + const cancelUnenrollmentDialogButton = content.document.getElementById( + "leave-ion-cancel-dialog-button" + ); + cancelUnenrollmentDialogButton.click(); + + const ionStillEnrolled = Services.prefs.getStringPref(PREF_ION_ID, null); + + ok( + ionStillEnrolled, + "after canceling unenrollment, Ion pref is still set." + ); + + enrollmentButton.click(); + + const acceptUnenrollmentDialogButton = content.document.getElementById( + "leave-ion-accept-dialog-button" + ); + + acceptUnenrollmentDialogButton.click(); + + // Wait for deletion ping, uninstalls, and UI updates... + const ionUnenrolled = await new Promise((resolve, reject) => { + Services.prefs.addObserver(PREF_ION_ID, function observer( + subject, + topic, + data + ) { + try { + const prefValue = Services.prefs.getStringPref(PREF_ION_ID, null); + Services.prefs.removeObserver(PREF_ION_ID, observer); + resolve(prefValue); + } catch (ex) { + Services.prefs.removeObserver(PREF_ION_ID, observer); + reject(ex); + } + }); + }); + + ok(!ionUnenrolled, "after accepting unenrollment, Ion pref is null."); + + const unenrolledToolbarButton = document.getElementById("ion-button"); + ok( + unenrolledToolbarButton.hidden, + "after unenrollment, Ion toolbar button is hidden." + ); + + await TelemetryStorage.testClearPendingPings(); + let pings = await TelemetryArchive.promiseArchivedPingList(); + + let pingDetails = []; + for (const ping of pings) { + ok( + ping.type == "pioneer-study", + "ping is of expected type pioneer-study" + ); + const details = await TelemetryArchive.promiseArchivedPingById(ping.id); + pingDetails.push(details.payload.studyName); + } + + Services.prefs.setStringPref(PREF_TEST_ADDONS, "[]"); + + for (const cachedAddon of CACHED_ADDONS) { + const addonId = cachedAddon.addon_id; + + ok( + pingDetails.includes(addonId), + "each test add-on has sent a deletion ping" + ); + + const joinButton = content.document.getElementById( + `${addonId}-join-button` + ); + + if (cachedAddon.isDefault) { + ok(!joinButton, "There is no join button for default study."); + } else { + ok( + joinButton.disabled, + "After unenrollment, join button is disabled." + ); + } + + for (const section of ["details", "data"]) { + ok( + content.document.getElementById(section).open === true, + "after unenrollment, dialog sections are open." + ); + } + } + } + ); +}); + +add_task(async function testEnrollmentPings() { + const CACHED_TEST_ADDON = CACHED_ADDONS[2]; + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify([CACHED_TEST_ADDON]); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + // Clear any pending pings. + await TelemetryStorage.testClearPendingPings(); + + // Check how many archived pings we already have, so that we can count new pings. + let beginPingCount = (await TelemetryArchive.promiseArchivedPingList()) + .length; + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(beforePref === null, "before enrollment, Ion pref is null."); + + // Enroll in ion. + const enrollmentButton = content.document.getElementById( + "enrollment-button" + ); + + let promiseDialogAccepted = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + enrollmentButton + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + enrollmentButton.getBoundingClientRect(); + enrollmentButton.click(); + + const acceptDialogButton = content.document.getElementById( + "join-ion-accept-dialog-button" + ); + acceptDialogButton.click(); + + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(ionId, "after enrollment, Ion pref is set."); + + await promiseDialogAccepted; + + // Enroll in the CACHED_TEST_ADDON study. + const joinButton = content.document.getElementById( + `${CACHED_TEST_ADDON.addon_id}-join-button` + ); + + const joinDialogOpen = new Promise(resolve => { + content.document + .getElementById("join-study-consent-dialog") + .addEventListener("open", () => { + resolve(); + }); + }); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + await joinDialogOpen; + + // Accept consent for the study. + const studyAcceptButton = content.document.getElementById( + "join-study-accept-dialog-button" + ); + + studyAcceptButton.click(); + + // Verify that the proper pings were generated. + let pings; + await TestUtils.waitForCondition(async function() { + pings = await TelemetryArchive.promiseArchivedPingList(); + return pings.length - beginPingCount >= 2; + }, "Wait until we have at least 2 pings in the telemetry archive"); + + let pingDetails = []; + for (const ping of pings) { + ok( + ping.type == "pioneer-study", + "ping is of expected type pioneer-study" + ); + const details = await TelemetryArchive.promiseArchivedPingById(ping.id); + pingDetails.push({ + schemaName: details.payload.schemaName, + schemaNamespace: details.payload.schemaNamespace, + studyName: details.payload.studyName, + pioneerId: details.payload.pioneerId, + }); + } + + // We expect 1 ping with just the ion id (ion consent) and another + // with both the ion id and the study id (study consent). + ok( + pingDetails.find( + p => + p.schemaName == "pioneer-enrollment" && + p.schemaNamespace == "pioneer-meta" && + p.pioneerId == ionId && + p.studyName == "pioneer-meta" + ), + "We expect the Ion program consent to be present" + ); + + ok( + pingDetails.find( + p => + p.schemaName == "pioneer-enrollment" && + p.schemaNamespace == CACHED_TEST_ADDON.addon_id && + p.pioneerId == ionId && + p.studyName == CACHED_TEST_ADDON.addon_id + ), + "We expect the study consent to be present" + ); + } + ); +}); + +add_task(async function testIonBadge() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_ION_NEW_STUDIES_AVAILABLE, true]], + clear: [ + [PREF_ION_NEW_STUDIES_AVAILABLE, false], + [PREF_ION_ID, ""], + ], + }); + + let ionTab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:ion", + gBrowser, + }); + + const enrollmentButton = content.document.getElementById("enrollment-button"); + enrollmentButton.click(); + + let blankTab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:home", + gBrowser, + }); + + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, true); + + const toolbarButton = document.getElementById("ion-button"); + const toolbarBadge = toolbarButton.querySelector(".toolbarbutton-badge"); + + ok( + toolbarBadge.classList.contains("feature-callout"), + "When pref is true, Ion toolbar button is called out in the current window." + ); + + toolbarButton.click(); + + await ionTab; + + ok( + !toolbarBadge.classList.contains("feature-callout"), + "When about:ion toolbar button is pressed, call-out is removed." + ); + + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, true); + + const newWin = await BrowserTestUtils.openNewBrowserWindow(); + const newToolbarBadge = toolbarButton.querySelector(".toolbarbutton-badge"); + + ok( + newToolbarBadge.classList.contains("feature-callout"), + "When pref is true, Ion toolbar button is called out in a new window." + ); + + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.removeTab(ionTab); + await BrowserTestUtils.removeTab(blankTab); +}); + +add_task(async function testContentReplacement() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [[PREF_ION_ID, ""]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + // Check that text was updated from Remote Settings. + console.log("debug:", content.document.getElementById("title").innerHTML); + ok( + content.document.getElementById("title").innerHTML == + "<b>test title</b><p>test title line 2</p>", + "Title was replaced correctly." + ); + ok( + content.document.getElementById("summary").innerHTML == + "<p>test summary</p>test summary line 2", + "Summary was replaced correctly." + ); + ok( + content.document.getElementById("details").innerHTML == + "<ol><li>test details</li><li>test details line 2</li><li>test details line</li></ol>", + "Details was replaced correctly." + ); + ok( + content.document.getElementById("data").innerHTML == "<b>test data</b>", + "Data was replaced correctly." + ); + ok( + content.document.getElementById("join-ion-consent").innerHTML == + "<p>test join consent</p><p>join consent line 2</p>", + "Join consent was replaced correctly." + ); + ok( + content.document.getElementById("leave-ion-consent").innerHTML == + "<p>test leave consent</p><p>test leave consent line 2</p>", + "Leave consent was replaced correctly." + ); + } + ); +}); + +add_task(async function testBadContentReplacement() { + const cachedContent = JSON.stringify([ + { + joinIonConsent: "<script>alert('failed')</script>", + leaveIonConsent: `<a href="blob:blah">blob</a>`, + }, + ]); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [[PREF_ION_ID, ""]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + // Check that text was updated from Remote Settings. + ok( + content.document.getElementById("join-ion-consent").innerHTML == "", + "Script tags are skipped." + ); + ok( + content.document.getElementById("leave-ion-consent").innerHTML == + "<a>blob</a>", + "Bad HREFs are stripped." + ); + } + ); +}); + +add_task(async function testLocaleGating() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify(CACHED_ADDONS); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + await setupLocale("de"); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const localeNotificationBar = content.document.getElementById( + "locale-notification" + ); + + is( + Services.locale.requestedLocales[0], + "de", + "The requestedLocales has been set to German ('de')." + ); + + is( + getComputedStyle(localeNotificationBar).display, + "block", + "Because the page locale is German, the notification bar is not hidden." + ); + } + ); + + await clearLocale(); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const localeNotificationBar = content.document.getElementById( + "locale-notification" + ); + + is( + Services.locale.requestedLocales[0], + "en-US", + "The requestedLocales has been set to English ('en-US')." + ); + + is( + getComputedStyle(localeNotificationBar).display, + "none", + "Because the page locale is en-US, the notification bar is hidden." + ); + } + ); +}); |