diff options
Diffstat (limited to 'browser/components/colorways')
19 files changed, 1925 insertions, 0 deletions
diff --git a/browser/components/colorways/ColorwayClosetOpener.jsm b/browser/components/colorways/ColorwayClosetOpener.jsm new file mode 100644 index 0000000000..f6dd3a0e2d --- /dev/null +++ b/browser/components/colorways/ColorwayClosetOpener.jsm @@ -0,0 +1,35 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["ColorwayClosetOpener"]; + +const { BrowserWindowTracker } = ChromeUtils.import( + "resource:///modules/BrowserWindowTracker.jsm" +); + +let ColorwayClosetOpener = { + /** + * Opens the colorway closet modal. + * @param {String} source + * Indicates from where the modal was opened, and it is used for existing telemetry probes. + * Valid "source" types include: "aboutaddons", "firefoxview" and "unknown" (default). + * @param {Function} onClosed + * Function that is called after the modal is closed. + * @See Events.yaml for existing colorway closet probes + */ + openModal: ({ source = "unknown", onClosed = null } = {}) => { + let { gBrowser } = BrowserWindowTracker.getTopWindow(); + let dialogBox = gBrowser.getTabDialogBox(gBrowser.selectedBrowser); + return dialogBox.open( + "chrome://browser/content/colorways/colorwaycloset.html", + { + features: "resizable=no", + sizeTo: "available", + }, + { source, onClosed } + ); + }, +}; diff --git a/browser/components/colorways/assets/independent-voices-activist.avif b/browser/components/colorways/assets/independent-voices-activist.avif Binary files differnew file mode 100644 index 0000000000..34a74742eb --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-activist.avif diff --git a/browser/components/colorways/assets/independent-voices-collection-banner.avif b/browser/components/colorways/assets/independent-voices-collection-banner.avif Binary files differnew file mode 100644 index 0000000000..75e75e622e --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-collection-banner.avif diff --git a/browser/components/colorways/assets/independent-voices-collection.avif b/browser/components/colorways/assets/independent-voices-collection.avif Binary files differnew file mode 100644 index 0000000000..3742723c59 --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-collection.avif diff --git a/browser/components/colorways/assets/independent-voices-dreamer.avif b/browser/components/colorways/assets/independent-voices-dreamer.avif Binary files differnew file mode 100644 index 0000000000..c2574395ce --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-dreamer.avif diff --git a/browser/components/colorways/assets/independent-voices-expressionist.avif b/browser/components/colorways/assets/independent-voices-expressionist.avif Binary files differnew file mode 100644 index 0000000000..dde3c1a4ce --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-expressionist.avif diff --git a/browser/components/colorways/assets/independent-voices-innovator.avif b/browser/components/colorways/assets/independent-voices-innovator.avif Binary files differnew file mode 100644 index 0000000000..a03d8f92ad --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-innovator.avif diff --git a/browser/components/colorways/assets/independent-voices-playmaker.avif b/browser/components/colorways/assets/independent-voices-playmaker.avif Binary files differnew file mode 100644 index 0000000000..b67c7adede --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-playmaker.avif diff --git a/browser/components/colorways/assets/independent-voices-visionary.avif b/browser/components/colorways/assets/independent-voices-visionary.avif Binary files differnew file mode 100644 index 0000000000..82d3a35933 --- /dev/null +++ b/browser/components/colorways/assets/independent-voices-visionary.avif diff --git a/browser/components/colorways/colorwaycloset.css b/browser/components/colorways/colorwaycloset.css new file mode 100644 index 0000000000..073ea17688 --- /dev/null +++ b/browser/components/colorways/colorwaycloset.css @@ -0,0 +1,268 @@ +/* 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/. */ + +:root { + height: 100%; + + /* This will give us a larger base font size on macOS: */ + font: menu; + + --body-columns: 1fr; + --body-rows: auto auto 1fr auto; + + --figure-width: min(37.5vw, 300px); + + --card-border-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%); + + --colorway-selector-align: center; + --colorway-name-font-size: 1.5em; + + --homepage-reset-column: 1; + --homepage-reset-align: start; +} + +@media (max-width: 560px) { + :root { + --font-scale: 0.9em; + } +} + +@media (min-width: 640px) { + :root { + --body-columns: 1em auto 1fr; + --body-rows: auto 1fr auto; + + --header-column: 2/4; + + --figure-column: 2; + --figure-row: 2; + + --customization-panel-column: 3; + --customization-panel-padding-inline-start: 2.5em; + + --colorway-selector-align: start; + --colorway-name-font-size: 2em; + + --homepage-reset-column: 1/4; + --homepage-reset-align: end; + } +} + +body { + height: 100%; + display: grid; + grid-template-columns: var(--body-columns); + grid-template-rows: var(--body-rows); + padding: 0 2em 1em; + box-sizing: border-box; + font-size: var(--font-scale); +} + +fieldset { + border: unset; + margin: 0; + padding: 0; +} + +/* Header */ + +body > header { + grid-row: 1; + margin-top: 1em; + grid-column: var(--header-column); +} + +#collection-title { + display: inline-block; + margin-inline: 0 .7em; + margin-block: 0 .2em; + padding: 0; + font-size: 1.5em; + font-weight: bold; +} + +#collection-expiry-date { + display: inline-block; + background: var(--card-border-zap-gradient); + background-origin: border-box; + border: 1px solid transparent; + border-radius: 1.5em; +} + +#collection-expiry-date > span { + display: inline-block; + color: var(--in-content-page-color); + background: var(--in-content-page-background); + border-radius: 1.5em; + padding: .3em 1em; +} + +/* Illustration */ + +figure { + grid-column: var(--figure-column); + grid-row: var(--figure-row); + + display: flex; + align-items: center; + justify-content: center; + + margin: 1em 0 0; + min-width: var(--figure-width); + min-height: var(--figure-width); +} + +figure > img { + max-width: var(--figure-width); + max-height: var(--figure-width); + object-fit: scale-down; +} + +/* Selector and colorway details */ + +#colorway-customization-panel { + align-self: stretch; + padding-inline-start: var(--customization-panel-padding-inline-start); + grid-column: var(--customization-panel-column); + display: flex; + flex-direction: column; +} + +#colorway-customization-panel > * { + flex: 1; +} + +#colorway-selector, +#modal-buttons { + flex: 3; + display: flex; + align-items: center; +} + +#colorway-selector { + justify-content: var(--colorway-selector-align); +} + +#colorway-selector > input[type="radio"], +#colorway-selector > input[type="radio"]:checked { + box-sizing: content-box; + padding: 2px; + border: 2px solid transparent; + height: 24px; + width: 24px; + --colorway-icon: none; + appearance: none; + background-color: unset; + background-image: var(--colorway-icon); + background-origin: content-box; + background-position: center; + background-repeat: no-repeat; + /* The icon may not be a perfect circle, so we render it bigger and clipped using background-clip and border-radius: */ + background-clip: content-box; + background-size: 105%; + border-radius: 50%; +} + +#colorway-selector > input[type="radio"]:enabled:checked, +#colorway-selector > input[type="radio"]:enabled:checked:hover { + border-color: var(--in-content-accent-color); +} + +/* override common-shared.css */ +#colorway-selector > input[type="radio"]:is(:enabled:hover, :enabled:hover:active, :checked, :enabled:checked:hover, :enabled:checked:hover:active) { + background-color: unset; +} + +#colorway-name { + font-size: var(--colorway-name-font-size); + margin: 0; +} + +#colorway-description { + line-height: 1.5; + flex: 2; +} + +/* Intensity Picker */ + +#colorway-intensities > legend { + padding-inline-start: 0; + margin-bottom: .5em; + color: var(--in-content-deemphasized-text); +} + +#colorway-intensity-radios { + display: flex; + justify-content: space-between; + gap: .5em; +} + +#colorway-intensity-radios > label { + background-color: var(--in-content-box-background-color); + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + color: var(--in-content-text-color); + + flex: 1; + overflow: clip; + padding: .5em; + + display: flex; + align-items: center; +} + +.colorway-intensity-radio { + margin-block: 0 !important; +} + +#set-colorway { + margin-inline-start: 0; +} + +/* Homepage reset footer */ + +#homepage-reset-container:not([hidden]) { + display: flex; + grid-column: var(--homepage-reset-column); +} + +#homepage-reset-prompt, +#homepage-reset-success { + width: 100%; + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: var(--homepage-reset-align); +} + +#homepage-reset-prompt > span, +#homepage-reset-success > span { + padding-inline-end: 1em; +} + +#homepage-reset-container:not(.success) > #homepage-reset-success, +#homepage-reset-container.success > #homepage-reset-prompt { + display: none; +} + +#homepage-reset-success > span { + position: relative; + padding-inline-start: calc(22px + .5em); +} + +#homepage-reset-success > span::before { + content: ""; + + background: var(--green-70) url('chrome://global/skin/icons/check.svg') center center no-repeat; + -moz-context-properties: fill; + fill: white; + border-radius: 100%; + + width: 22px; + height: 22px; + + position: absolute; + inset-inline-start: 0; + inset-block-start: calc((1em - 22px) / 2); +} diff --git a/browser/components/colorways/colorwaycloset.html b/browser/components/colorways/colorwaycloset.html new file mode 100644 index 0000000000..2956b64c2b --- /dev/null +++ b/browser/components/colorways/colorwaycloset.html @@ -0,0 +1,68 @@ +<!-- 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 style="max-width: 67em; max-height: 40em;"> +<head> + <meta charset="utf-8"> + <meta http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'"> + <meta name="color-scheme" content="light dark"> + <title data-l10n-id="colorways-modal-title"></title> + <link rel="stylesheet" type="text/css" + href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" type="text/css" + href="chrome://browser/content/colorways/colorwaycloset.css"> + <link rel="localization" href="branding/brand.ftl"> + <link rel="localization" href="browser/branding/brandings.ftl"> + <link rel="localization" href="browser/colorwaycloset.ftl"> + <link rel="localization" href="browser/colorways.ftl"> + <script src="chrome://browser/content/colorways/colorwaycloset.js" defer></script> +</head> +<body> + <header> + <h1 id="collection-title"></h1> + <span id="collection-expiry-date"><span></span></span> + </header> + <figure role="presentation"> + <img id="colorway-figure" role="presentation"> + </figure> + <main id="colorway-customization-panel"> + <fieldset id="colorway-selector"></fieldset> + <h2 id="colorway-name"></h2> + <p id="colorway-description"></p> + <fieldset id="colorway-intensities"> + <legend data-l10n-id="colorway-intensity-selector-label"></legend> + <div id="colorway-intensity-radios"> + <label> + <input type="radio" name="intensity" class="colorway-intensity-radio" data-intensity="soft"> + <span data-l10n-id="colorway-intensity-soft"></span> + </label> + <label> + <input type="radio" name="intensity" class="colorway-intensity-radio" data-intensity="balanced"> + <span data-l10n-id="colorway-intensity-balanced"></span> + </label> + <label> + <input type="radio" name="intensity" class="colorway-intensity-radio" data-intensity="bold"> + <span data-l10n-id="colorway-intensity-bold"></span> + </label> + </div> + </fieldset> + <fieldset id="modal-buttons"> + <button id="set-colorway" data-l10n-id="colorway-closet-set-colorway-button" class="primary"></button> + <button id="cancel" data-l10n-id="colorway-closet-cancel-button"></button> + </fieldset> + </main> + <footer id="homepage-reset-container" hidden> + <div id="homepage-reset-prompt"> + <span data-l10n-id="colorway-homepage-reset-prompt"></span> + <button data-l10n-id="colorway-homepage-reset-apply-button"></button> + </div> + <div id="homepage-reset-success"> + <span data-l10n-id="colorway-homepage-reset-success-message"></span> + <button data-l10n-id="colorway-homepage-reset-undo-button"></button> + </div> + </footer> +</body> +</html> diff --git a/browser/components/colorways/colorwaycloset.js b/browser/components/colorways/colorwaycloset.js new file mode 100644 index 0000000000..63d9601b8d --- /dev/null +++ b/browser/components/colorways/colorwaycloset.js @@ -0,0 +1,332 @@ +/* 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 { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +const INTENSITY_SOFT = "soft"; +const INTENSITY_BALANCED = "balanced"; +const INTENSITY_BOLD = "bold"; +const ID_SUFFIX_COLORWAY = "-colorway@mozilla.org"; +const ID_SUFFIX_PRIMARY_INTENSITY = `-${INTENSITY_BALANCED}${ID_SUFFIX_COLORWAY}`; +const ID_SUFFIX_DARK_COLORWAY = `-${INTENSITY_BOLD}${ID_SUFFIX_COLORWAY}`; +const ID_SUFFIXES_FOR_SECONDARY_INTENSITIES = new RegExp( + `-(${INTENSITY_SOFT}|${INTENSITY_BOLD})-colorway@mozilla\\.org$` +); +const MATCH_INTENSITY_FROM_ID = new RegExp( + `-(${INTENSITY_SOFT}|${INTENSITY_BALANCED}|${INTENSITY_BOLD})-colorway@mozilla\\.org$` +); + +/** + * Helper for colorway closet related telemetry probes. + */ +const ColorwaysTelemetry = { + init() { + Services.telemetry.setEventRecordingEnabled("colorways_modal", true); + }, + + recordEvent(telemetryEventData) { + if (!telemetryEventData || !Object.entries(telemetryEventData).length) { + console.error("Unable to record event due to missing telemetry data"); + return; + } + + if (telemetryEventData.source && ColorwayCloset.previousTheme?.id) { + telemetryEventData.method = BuiltInThemes.isColorwayFromCurrentCollection( + ColorwayCloset.previousTheme.id + ) + ? "change_colorway" + : "try_colorways"; + telemetryEventData.object = telemetryEventData.source; + } + Services.telemetry.recordEvent( + "colorways_modal", + telemetryEventData.method, + telemetryEventData.object, + telemetryEventData.value || null, + telemetryEventData.extraKeys || null + ); + }, +}; + +const ColorwayCloset = { + // This is essentially an instant-apply dialog, but we make an effort to only + // keep the theme change if the user hits the "Set colorway" button, and + // otherwise revert to the previous theme upon closing the modal. However, + // this doesn't cover the application quitting while the modal is open, in + // which case the theme change will be kept. + revertToPreviousTheme: true, + + el: { + colorwayRadios: document.getElementById("colorway-selector"), + intensityContainer: document.getElementById("colorway-intensities"), + colorwayFigure: document.getElementById("colorway-figure"), + colorwayName: document.getElementById("colorway-name"), + collectionTitle: document.getElementById("collection-title"), + colorwayDescription: document.getElementById("colorway-description"), + expiryDateSpan: document.querySelector("#collection-expiry-date > span"), + setColorwayButton: document.getElementById("set-colorway"), + cancelButton: document.getElementById("cancel"), + homepageResetContainer: document.getElementById("homepage-reset-container"), + }, + + init() { + window.addEventListener("unload", this); + + ColorwaysTelemetry.init(); + + this._displayCollectionData(); + + AddonManager.addAddonListener(this); + this._initColorwayRadios().then(() => { + this.el.colorwayRadios.addEventListener("change", this); + this.el.intensityContainer.addEventListener("change", this); + + let args = window?.arguments?.[0]; + // Record telemetry probes that are called upon opening the modal. + ColorwaysTelemetry.recordEvent(args); + + this.el.setColorwayButton.onclick = () => { + this.revertToPreviousTheme = false; + window.close(); + }; + }); + + this.el.cancelButton.onclick = () => { + window.close(); + }; + + this._displayHomepageResetOption(); + }, + + async _initColorwayRadios() { + BuiltInThemes.ensureBuiltInThemes(); + let themes = await AddonManager.getAddonsByTypes(["theme"]); + this.previousTheme = themes.find(theme => theme.isActive); + this.colorways = themes.filter(theme => + BuiltInThemes.isColorwayFromCurrentCollection(theme.id) + ); + + // The radio buttons represent colorway "groups". A group is a colorway + // from the current collection to represent related colorways with another + // intensity. If the current collection doesn't have intensities, each + // colorway is their own group. + this.colorwayGroups = this.colorways.filter( + colorway => !ID_SUFFIXES_FOR_SECONDARY_INTENSITIES.test(colorway.id) + ); + + for (const addon of this.colorwayGroups) { + let input = document.createElement("input"); + input.type = "radio"; + input.name = "colorway"; + input.value = addon.id; + input.setAttribute("title", this._getColorwayGroupName(addon)); + if (addon.iconURL) { + input.style.setProperty("--colorway-icon", `url(${addon.iconURL})`); + } + this.el.colorwayRadios.appendChild(input); + } + + // If the current active theme is part of our collection, make the UI reflect + // that. Otherwise go ahead and enable the first colorway in our list. + this.selectedColorway = this.colorways.find(colorway => colorway.isActive); + if (this.selectedColorway) { + this.refresh(); + } else { + let colorwayToEnable = this.colorwayGroups[0]; + // If the user has been using a theme with a dark color scheme, make an + // effort to default to a colorway with a dark color scheme as well. + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + let firstDarkColorway = this.colorways.find(colorway => + colorway.id.endsWith(ID_SUFFIX_DARK_COLORWAY) + ); + colorwayToEnable = firstDarkColorway || colorwayToEnable; + } + colorwayToEnable.enable(); + } + }, + + _displayHomepageResetOption() { + const { HomePage } = ChromeUtils.import("resource:///modules/HomePage.jsm"); + this.el.homepageResetContainer.hidden = HomePage.isDefault; + if (!HomePage.isDefault) { + let homeState; + this.el.homepageResetContainer + .querySelector("#homepage-reset-prompt > button") + .addEventListener("click", () => { + ColorwaysTelemetry.recordEvent({ + method: "homepage_reset", + object: "modal", + }); + homeState = HomePage.get(); + HomePage.reset(); + this.el.homepageResetContainer.classList.add("success"); + }); + this.el.homepageResetContainer + .querySelector("#homepage-reset-success > button") + .addEventListener("click", () => { + ColorwaysTelemetry.recordEvent({ + method: "homepage_reset_undo", + object: "modal", + }); + HomePage.set(homeState); + this.el.homepageResetContainer.classList.remove("success"); + }); + } + }, + + _displayCollectionData() { + const collection = BuiltInThemes.findActiveColorwayCollection(); + if (!collection) { + // Whoops. There should be no entry point to this UI without an active + // collection. + window.close(); + } + document.l10n.setAttributes( + this.el.collectionTitle, + collection.l10nId.title + ); + document.l10n.setAttributes( + this.el.expiryDateSpan, + "colorway-collection-expiry-label", + { + expiryDate: collection.expiry.getTime(), + } + ); + }, + + _getFigureUrl() { + return BuiltInThemes.builtInThemeMap.get(this.selectedColorway.id) + .figureUrl; + }, + + _displayColorwayData() { + this.el.colorwayName.innerText = this._getColorwayGroupName( + this.selectedColorway + ); + this.el.colorwayDescription.innerText = this.selectedColorway.description; + this.el.colorwayFigure.src = this._getFigureUrl(); + + this.el.intensityContainer.hidden = !this.hasIntensities; + if (this.hasIntensities) { + let selectedIntensity = this.selectedColorway.id.match( + MATCH_INTENSITY_FROM_ID + )[1]; + for (let radio of document.querySelectorAll( + ".colorway-intensity-radio" + )) { + let intensity = radio.getAttribute("data-intensity"); + radio.value = this._changeIntensity( + this.selectedColorway.id, + intensity + ); + if (intensity == selectedIntensity) { + radio.checked = true; + } + } + } + }, + + _getColorwayGroupId(colorwayId) { + let groupId = colorwayId.replace( + ID_SUFFIXES_FOR_SECONDARY_INTENSITIES, + ID_SUFFIX_PRIMARY_INTENSITY + ); + return this.colorwayGroups.map(addon => addon.id).includes(groupId) + ? groupId + : null; + }, + + _changeIntensity(colorwayId, intensity) { + return colorwayId.replace( + MATCH_INTENSITY_FROM_ID, + `-${intensity}${ID_SUFFIX_COLORWAY}` + ); + }, + + _getColorwayGroupName(addon) { + return BuiltInThemes.getLocalizedColorwayGroupName(addon.id) || addon.name; + }, + + handleEvent(e) { + switch (e.type) { + case "change": + let newId = e.target.value; + // Persist the selected intensity when toggling between colorway radios. + if (e.currentTarget == this.el.colorwayRadios && this.hasIntensities) { + let selectedIntensity = document + .querySelector(".colorway-intensity-radio:checked") + .getAttribute("data-intensity"); + newId = this._changeIntensity(newId, selectedIntensity); + } + this.colorways.find(colorway => colorway.id == newId).enable(); + break; + case "unload": + AddonManager.removeAddonListener(this); + if (this.revertToPreviousTheme) { + this.previousTheme.enable(); + ColorwaysTelemetry.recordEvent({ + method: "cancel", + object: "modal", + }); + } else { + ColorwaysTelemetry.recordEvent({ + method: "set_colorway", + object: "modal", + value: null, + extraKeys: { colorway_id: this.selectedColorway.id }, + }); + } + + // `onClosed` is an additional callback passed upon opening the modal. + // Here, `onClosed` is passed by about:addons and is called after the + // the modal closes. This is to defer re-rendering the about:addons page + // until the user has closed the dialog by setting a new colorways theme + // or pressing cancel to revert to the previous theme. + const { onClosed } = window?.arguments?.[0] || {}; + onClosed?.({ colorwayChanged: !this.revertToPreviousTheme }); + break; + } + }, + + onEnabled(addon) { + if (addon.type == "theme") { + if (!this.colorways.find(colorway => colorway.id == addon.id)) { + // The selected theme changed to a non-colorway from outside the modal. + // This UI can't represent that state, so bail out. + this.revertToPreviousTheme = false; + window.close(); + return; + } + this.selectedColorway = addon; + this.refresh(); + } + }, + + refresh() { + this.groupIdForSelectedColorway = this._getColorwayGroupId( + this.selectedColorway.id + ); + this.hasIntensities = this.groupIdForSelectedColorway.endsWith( + ID_SUFFIX_PRIMARY_INTENSITY + ); + for (let input of this.el.colorwayRadios.children) { + if (input.value == this.groupIdForSelectedColorway) { + input.checked = true; + this._displayColorwayData(); + break; + } + } + this.el.setColorwayButton.disabled = + this.previousTheme.id == this.selectedColorway.id; + }, +}; + +ColorwayCloset.init(); diff --git a/browser/components/colorways/jar.mn b/browser/components/colorways/jar.mn new file mode 100644 index 0000000000..cac5627f9d --- /dev/null +++ b/browser/components/colorways/jar.mn @@ -0,0 +1,9 @@ +# 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/colorways/colorwaycloset.html + content/browser/colorways/colorwaycloset.css + content/browser/colorways/colorwaycloset.js + content/browser/colorways/assets/ (assets/*.avif) diff --git a/browser/components/colorways/moz.build b/browser/components/colorways/moz.build new file mode 100644 index 0000000000..17eb613713 --- /dev/null +++ b/browser/components/colorways/moz.build @@ -0,0 +1,14 @@ +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Theme") + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "ColorwayClosetOpener.jsm", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] diff --git a/browser/components/colorways/tests/browser/browser.ini b/browser/components/colorways/tests/browser/browser.ini new file mode 100644 index 0000000000..02588ca661 --- /dev/null +++ b/browser/components/colorways/tests/browser/browser.ini @@ -0,0 +1,8 @@ +[DEFAULT] +run-if = nightly_build +support-files = + head.js + +[browser_colorwayCloset_modalButtons.js] +[browser_colorwayCloset_modalUI.js] +[browser_colorwayCloset_telemetry.js] diff --git a/browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js new file mode 100644 index 0000000000..972e7d3eed --- /dev/null +++ b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the selected colorway is still enabled after pressing the Set Colorway button + * in the modal. + */ +add_task(async function colorwaycloset_modal_set_colorway() { + await testInColorwayClosetModal(async (document, contentWindow) => { + // Select colorway on modal + info("Selecting colorway radio button"); + const { + colorwayIntensities, + setColorwayButton, + } = getColorwayClosetTestElements(document); + + // Select new intensity + // Wait for intensity button to be initialized + await BrowserTestUtils.waitForMutationCondition( + colorwayIntensities, + { subtree: true, attributeFilter: ["value"] }, + () => + colorwayIntensities.querySelector( + `input[value="${SOFT_COLORWAY_THEME_ID}"]` + ), + "Waiting for intensity button to be available" + ); + let intensitiesChangedPromise = BrowserTestUtils.waitForEvent( + colorwayIntensities, + "change", + "Waiting for intensities change event" + ); + let themeChangedPromise = waitForAddonEnabled(SOFT_COLORWAY_THEME_ID); + + info("Selecting new intensity"); + colorwayIntensities + .querySelector(`input[value="${SOFT_COLORWAY_THEME_ID}"]`) + .click(); + await intensitiesChangedPromise; + await themeChangedPromise; + + // Set colorway + await BrowserTestUtils.waitForMutationCondition( + setColorwayButton, + { childList: true, attributeFilter: ["disabled"] }, + () => !setColorwayButton.disabled, + "Waiting for set-colorway button to be available for selection" + ); + let modalClosedPromise = BrowserTestUtils.waitForEvent( + contentWindow, + "unload", + "Waiting for modal to close" + ); + + info("Selecting set colorway button"); + setColorwayButton.click(); + info("Closing modal"); + await modalClosedPromise; + + // Verify theme selection is saved + const activeTheme = Services.prefs.getStringPref( + "extensions.activeThemeID" + ); + is( + activeTheme, + SOFT_COLORWAY_THEME_ID, + "Current theme is still selected colorway" + ); + }); +}); + +/** + * Tests that the selected colorway is not enabled after pressing the Cancel Button. + * Theme should be reverted to previous option. + */ +add_task(async function colorwaycloset_modal_cancel() { + const previousTheme = Services.prefs.getStringPref( + "extensions.activeThemeID" + ); + info(`Previous theme is ${previousTheme}`); + + await testInColorwayClosetModal( + async (document, contentWindow) => { + // Wait for colorway to load before checking modal UI + await waitForAddonEnabled(NO_INTENSITY_COLORWAY_THEME_ID); + + // Cancel colorway selection + const { cancelButton } = getColorwayClosetTestElements(document); + let themeRevertedPromise = waitForAddonEnabled(previousTheme); + let modalClosedPromise = BrowserTestUtils.waitForEvent( + contentWindow, + "unload", + "Waiting for modal to close" + ); + info("Selecting cancel button"); + cancelButton.click(); + + info("Verifying that previous theme is restored"); + await themeRevertedPromise; + info("Closing modal"); + await modalClosedPromise; + }, + [NO_INTENSITY_COLORWAY_THEME_ID] + ); +}); + +/** + * Tests that the Firefox homepage apply and undo options are visible + * on the modal if a non-default setting for homepage is enabled. + */ +add_task(async function colorwaycloset_custom_home_page() { + await HomePage.set("https://www.example.com"); + await testInColorwayClosetModal(document => { + const { homepageResetApplyButton } = getColorwayClosetTestElements( + document + ); + let v = getColorwayClosetElementVisibility(document); + ok( + v.homepageResetContainer.isVisible, + "Homepage reset prompt should be shown" + ); + ok( + v.homepageResetSuccessMessage.isHidden, + "Success message should not be shown" + ); + ok(v.homepageResetUndoButton.isHidden, "Undo button should not be shown"); + ok(v.homepageResetMessage.isVisible, "Reset message should be shown"); + ok(v.homepageResetApplyButton.isVisible, "Apply button should be shown"); + + homepageResetApplyButton.click(); + v = getColorwayClosetElementVisibility(document); + + ok( + v.homepageResetSuccessMessage.isVisible, + "Success message should be shown" + ); + ok(v.homepageResetUndoButton.isVisible, "Undo button should be shown"); + ok(v.homepageResetMessage.isHidden, "Reset message should not be shown"); + ok(v.homepageResetApplyButton.isHidden, "Apply button should not be shown"); + }); +}); + +/** + * Tests that the Firefox homepage apply and undo options are not visible + * on the modal if the default setting for homepage is enabled. + */ +add_task(async function colorwaycloset_default_home_page() { + await HomePage.set(HomePage.getOriginalDefault()); + await testInColorwayClosetModal(document => { + const { homepageResetContainer } = getColorwayClosetTestElements(document); + ok( + BrowserTestUtils.is_hidden(homepageResetContainer), + "Homepage reset prompt should be hidden" + ); + }); +}); diff --git a/browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js new file mode 100644 index 0000000000..82b2fe9274 --- /dev/null +++ b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js @@ -0,0 +1,379 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that all colorway details such as the collection title, collection expiry date, + * and colorway figure are displayed on the modal. + */ +add_task(async function colorwaycloset_show_colorway() { + await testInColorwayClosetModal(async document => { + is( + document.documentElement.style.width, + "", + "In order for the modal layout to be responsive, the modal document " + + "should not have a width set after the dialog frame has been set up" + ); + const el = getColorwayClosetTestElements(document); + const expiryL10nAttributes = document.l10n.getAttributes(el.expiryDateSpan); + is( + document.l10n.getAttributes(el.collectionTitle).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Correct collection title should be shown" + ); + is( + expiryL10nAttributes.args.expiryDate, + TEST_COLORWAY_COLLECTION.expiry.getTime(), + "Correct expiry date should be shown" + ); + is( + expiryL10nAttributes.id, + EXPIRY_DATE_L10N_ID, + "Correct expiry date format should be shown" + ); + + info("Verifying figure src"); + await BrowserTestUtils.waitForMutationCondition( + el.colorwayFigure, + { childList: true, attributeFilter: ["src"] }, + () => el.colorwayFigure.src === MOCK_THEME_FIGURE_URL, + "Waiting for figure image to have expected URL" + ); + }); +}); + +/** + * Tests that modal details and theme are updated when a new colorway radio button is selected. + */ +add_task(async function colorwaycloset_modal_select_colorway() { + await testInColorwayClosetModal( + async document => { + const { + colorwaySelector, + colorwayName, + colorwayDescription, + colorwayIntensities, + } = getColorwayClosetTestElements(document); + + is( + colorwaySelector.children.length, + 2, + "There should be two colorway radio buttons" + ); + + // Select colorway on modal + const colorwayGroupButton1 = colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ); + let selectorChangedPromise = BrowserTestUtils.waitForEvent( + colorwaySelector, + "change", + "Waiting for radio button change event" + ); + let themeChangedPromise = waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID); + + info("Selecting colorway radio button"); + colorwayGroupButton1.click(); + info("Waiting for radio button change event to resolve"); + await selectorChangedPromise; + info("Waiting for theme to change"); + await themeChangedPromise; + + // Verify values of first colorway + is(colorwayName.textContent, MOCK_THEME_NAME, "Theme name is correct"); + is( + colorwayDescription.textContent, + MOCK_THEME_DESCRIPTION, + "Theme description is correct" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${SOFT_COLORWAY_THEME_ID}"]` + ), + "Soft intensity should be correct" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ), + "Balanced intensity should be correct" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BOLD_COLORWAY_THEME_ID}"]` + ), + "Bold intensity should be correct" + ); + + // Select a new colorway + const colorwayGroupButton2 = colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID_2}"]` + ); + selectorChangedPromise = BrowserTestUtils.waitForEvent( + colorwaySelector, + "change", + "Waiting for radio button change event" + ); + themeChangedPromise = waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID_2); + + info("Selecting another colorway radio button"); + colorwayGroupButton2.click(); + info("Waiting for radio button change event to resolve"); + await selectorChangedPromise; + info("Waiting for theme to change"); + await themeChangedPromise; + + // Verify values of new colorway + is(colorwayName.textContent, MOCK_THEME_NAME_2, "Theme name is updated"); + is( + colorwayDescription.textContent, + MOCK_THEME_DESCRIPTION_2, + "Theme description is updated" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${SOFT_COLORWAY_THEME_ID_2}"]` + ), + "Soft intensity should be updated" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID_2}"]` + ), + "Balanced intensity should be updated" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BOLD_COLORWAY_THEME_ID_2}"]` + ), + "Bold intensity should be updated" + ); + }, + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + SOFT_COLORWAY_THEME_ID_2, + BALANCED_COLORWAY_THEME_ID_2, + BOLD_COLORWAY_THEME_ID_2, + ] + ); +}); + +/** + * Tests that modal details and theme are updated when a new colorway radio button is selected, + * but when the colorway has no intensity options. + */ +add_task(async function colorwaycloset_modal_select_colorway_no_intensity() { + await testInColorwayClosetModal( + async document => { + // Select colorway on modal + const { + colorwaySelector, + colorwayName, + colorwayDescription, + colorwayIntensities, + } = getColorwayClosetTestElements(document); + + is( + colorwaySelector.children.length, + 2, + "There should be two colorway radio buttons" + ); + + const colorwayGroupButton1 = colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ); + let selectorChangedPromise = BrowserTestUtils.waitForEvent( + colorwaySelector, + "change", + "Waiting for radio button change event" + ); + let themeChangedPromise = waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID); + + info("Selecting colorway radio button"); + colorwayGroupButton1.click(); + info("Waiting for radio button change event to resolve"); + await selectorChangedPromise; + info("Waiting for theme to change"); + await themeChangedPromise; + + // Verify values of first colorway + is(colorwayName.textContent, MOCK_THEME_NAME, "Theme name is correct"); + is( + colorwayDescription.textContent, + MOCK_THEME_DESCRIPTION, + "Theme description is correct" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${SOFT_COLORWAY_THEME_ID}"]` + ), + "Soft intensity should be correct" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ), + "Balanced intensity should be correct" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BOLD_COLORWAY_THEME_ID}"]` + ), + "Bold intensity should be correct" + ); + + // Select a new colorway + const colorwayGroupButton2 = colorwaySelector.querySelector( + `input[value="${NO_INTENSITY_COLORWAY_THEME_ID}"]` + ); + selectorChangedPromise = BrowserTestUtils.waitForEvent( + colorwaySelector, + "change", + "Waiting for radio button change event" + ); + themeChangedPromise = waitForAddonEnabled(NO_INTENSITY_COLORWAY_THEME_ID); + + info("Selecting another colorway radio button but with no intensity"); + colorwayGroupButton2.click(); + info("Waiting for radio button change event to resolve"); + await selectorChangedPromise; + info("Waiting for theme to change"); + await themeChangedPromise; + + // Verify there are no intensities + info("Verifying intensity button is not visible"); + let v = getColorwayClosetElementVisibility(document); + ok( + !v.colorwayIntensities.isVisible, + "Colorway intensities should not be shown" + ); + }, + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + NO_INTENSITY_COLORWAY_THEME_ID, + ] + ); +}); + +/** + * Tests that an active colorway in the current collection is selected and displayed when + * opening the modal. + */ +add_task(async function colorwaycloset_modal_show_active_colorway() { + await testInColorwayClosetModal( + async document => { + const { + colorwaySelector, + colorwayIntensities, + } = getColorwayClosetTestElements(document); + info( + "Verify that the correct colorway family button is checked by default" + ); + ok( + colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ).checked, + "Colorway group button should be checked by default" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${SOFT_COLORWAY_THEME_ID}"]` + ).checked, + "Soft intensity should be checked by default" + ); + }, + undefined, + SOFT_COLORWAY_THEME_ID + ); +}); + +/** + * Tests that a colorway available in the active collection is displayed in the + * colorway closet modal if the current theme is an expired colorway. + */ +add_task(async function colorwaycloset_modal_expired_colorway() { + const previousTheme = Services.prefs.getStringPref( + "extensions.activeThemeID" + ); + info(`Previous theme is ${previousTheme}`); + + await testInColorwayClosetModal( + async document => { + const { + colorwaySelector, + colorwayIntensities, + } = getColorwayClosetTestElements(document); + + // Wait for colorway to load before checking modal UI + await waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID); + + ok( + colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ).checked, + "Colorway group button should be checked by default" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ).checked, + "Correct intensity should be checked by default" + ); + }, + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID, + ], + NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID + ); +}); + +/** + * Tests that bold intensity is checked by default upon the opening the modal + * if the current theme has a dark scheme. + */ +add_task(async function colorwaycloset_modal_dark_scheme() { + await testInColorwayClosetModal( + async document => { + const { + colorwaySelector, + colorwayIntensities, + } = getColorwayClosetTestElements(document); + + const previousTheme = Services.prefs.getStringPref( + "extensions.activeThemeID" + ); + info(`Previous theme is ${previousTheme}`); + + // Wait for theme to load before checking modal UI + await waitForAddonEnabled(BOLD_COLORWAY_THEME_ID); + + ok( + colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ).checked, + "Colorway group button should be checked by default" + ); + ok( + colorwayIntensities.querySelector( + `input[value="${BOLD_COLORWAY_THEME_ID}"]` + ).checked, + "Bold intensity should be checked by default" + ); + }, + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + MOCK_DARK_THEME_ID, + ], + MOCK_DARK_THEME_ID + ); +}); diff --git a/browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js b/browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js new file mode 100644 index 0000000000..09fdd86766 --- /dev/null +++ b/browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js @@ -0,0 +1,290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +add_setup(async function setup_tests() { + Services.telemetry.clearEvents(); +}); + +/** + * Tests that telemetry is registered when the Cancel button is selected on the Colorway Closet modal. + */ +add_task(async function colorwaycloset_modal_cancel() { + await testInColorwayClosetModal(async (document, contentWindow) => { + Services.telemetry.clearEvents(); + registerCleanupFunction(() => { + Services.telemetry.clearEvents(); + }); + + const { cancelButton } = getColorwayClosetTestElements(document); + let modalClosedPromise = BrowserTestUtils.waitForEvent( + contentWindow, + "unload", + "Waiting for modal to close" + ); + + cancelButton.click(); + info("Closing modal"); + await modalClosedPromise; + await waitForColorwaysTelemetryPromise(); + + TelemetryTestUtils.assertEvents( + [ + { + category: "colorways_modal", + method: "cancel", + object: "modal", + }, + ], + { category: "colorways_modal", object: "modal" } + ); + Services.telemetry.clearEvents(); + }); +}); + +/** + * Tests that telemetry is registered when the Set Colorway button is selected on the Colorway Closet modal. + */ +add_task(async function colorwaycloset_modal_set_colorway() { + await testInColorwayClosetModal(async (document, contentWindow) => { + Services.telemetry.clearEvents(); + registerCleanupFunction(() => { + Services.telemetry.clearEvents(); + }); + + // Select colorway on modal + info("Selecting colorway radio button"); + const { + colorwaySelector, + colorwayIntensities, + setColorwayButton, + } = getColorwayClosetTestElements(document); + const colorwayFamilyButton = colorwaySelector.querySelector( + `input[value="${BALANCED_COLORWAY_THEME_ID}"]` + ); + colorwayFamilyButton.click(); + + // Select new intensity + info("Selecting new intensity button"); + await BrowserTestUtils.waitForMutationCondition( + colorwayIntensities, + { subtree: true, attributeFilter: ["value"] }, + () => + colorwayIntensities.querySelector( + `input[value="${SOFT_COLORWAY_THEME_ID}"]` + ), + "Waiting for intensity button to be available" + ); + let intensitiesChangedPromise = BrowserTestUtils.waitForEvent( + colorwayIntensities, + "change", + "Waiting for intensities change event" + ); + let themeChangedPromise = BrowserTestUtils.waitForCondition(() => { + const activeTheme = Services.prefs.getStringPref( + "extensions.activeThemeID" + ); + return activeTheme === SOFT_COLORWAY_THEME_ID; + }, "Waiting for the current theme to change after new intensity"); + + colorwayIntensities + .querySelector(`input[value="${SOFT_COLORWAY_THEME_ID}"]`) + .click(); + await intensitiesChangedPromise; + await themeChangedPromise; + + // Set colorway + info("Selecting set colorway button"); + await BrowserTestUtils.waitForMutationCondition( + setColorwayButton, + { childList: true, attributeFilter: ["disabled"] }, + () => !setColorwayButton.disabled, + "Waiting for set-colorway button to be available for selection" + ); + let modalClosedPromise = BrowserTestUtils.waitForEvent( + contentWindow, + "unload", + "Waiting for modal to close" + ); + + setColorwayButton.click(); + await waitForColorwaysTelemetryPromise(); + info("Closing modal"); + await modalClosedPromise; + + TelemetryTestUtils.assertEvents( + [ + { + category: "colorways_modal", + method: "set_colorway", + object: "modal", + value: null, + extra: { + colorway_id: SOFT_COLORWAY_THEME_ID, + }, + }, + ], + { category: "colorways_modal", object: "modal" } + ); + + Services.telemetry.clearEvents(); + }); +}); + +/** + * Tests that telemetry is registered when the Set Colorway button is selected on the Colorway Closet modal, + * but for a selected colorway theme that does not have an intensity. + */ +add_task(async function colorwaycloset_modal_set_colorway_no_intensity() { + await testInColorwayClosetModal( + async (document, contentWindow) => { + Services.telemetry.clearEvents(); + registerCleanupFunction(() => { + Services.telemetry.clearEvents(); + }); + const { + colorwaySelector, + setColorwayButton, + } = getColorwayClosetTestElements(document); + + // Select colorway on modal + info("Selecting colorway radio button"); + const colorwayFamilyButton = colorwaySelector.querySelector( + `input[value="${NO_INTENSITY_COLORWAY_THEME_ID}"]` + ); + let themeChangedPromise = BrowserTestUtils.waitForCondition(() => { + const activeTheme = Services.prefs.getStringPref( + "extensions.activeThemeID" + ); + return activeTheme === NO_INTENSITY_COLORWAY_THEME_ID; + }, "Waiting for the current theme to change after new intensity"); + colorwayFamilyButton.click(); + await themeChangedPromise; + + // Set colorway + info("Selecting set colorway button"); + await BrowserTestUtils.waitForMutationCondition( + setColorwayButton, + { childList: true, attributeFilter: ["disabled"] }, + () => !setColorwayButton.disabled, + "Waiting for set-colorway button to be available for selection" + ); + let modalClosedPromise = BrowserTestUtils.waitForEvent( + contentWindow, + "unload", + "Waiting for modal to close" + ); + + setColorwayButton.click(); + await waitForColorwaysTelemetryPromise(); + info("Closing modal"); + await modalClosedPromise; + + TelemetryTestUtils.assertEvents( + [ + { + category: "colorways_modal", + method: "set_colorway", + object: "modal", + value: null, + extra: { + colorway_id: NO_INTENSITY_COLORWAY_THEME_ID, + }, + }, + ], + { category: "colorways_modal", object: "modal" } + ); + + Services.telemetry.clearEvents(); + }, + [NO_INTENSITY_COLORWAY_THEME_ID] + ); +}); + +/** + * Tests that telemetry is registered when the Firefox Home apply and undo buttons are selected on the Colorway Closet modal. + */ +add_task(async function colorwaycloset_modal_firefox_home() { + // Set homepage to NOT Firefox Home so that banner appears on the modal + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.homepage", "about:blank"]], + }); + await testInColorwayClosetModal(async document => { + Services.telemetry.clearEvents(); + registerCleanupFunction(() => { + Services.telemetry.clearEvents(); + }); + + // Ensure Firefox Home banner is visible first on the modal + const { + homepageResetContainer, + homepageResetApplyButton, + homepageResetUndoButton, + } = getColorwayClosetTestElements(document); + + ok(homepageResetContainer, "Firefox Home banner is visible on the modal"); + ok(homepageResetApplyButton, "Firefox Home Apply button should be visible"); + + homepageResetApplyButton.click(); + await waitForColorwaysTelemetryPromise(); + + TelemetryTestUtils.assertEvents( + [ + { + category: "colorways_modal", + method: "homepage_reset", + object: "modal", + }, + ], + { category: "colorways_modal", object: "modal" } + ); + + ok(homepageResetUndoButton, "Firefox Home Undo button should be visible"); + + homepageResetUndoButton.click(); + await waitForColorwaysTelemetryPromise(); + + TelemetryTestUtils.assertEvents( + [ + { + category: "colorways_modal", + method: "homepage_reset_undo", + object: "modal", + }, + ], + { category: "colorways_modal", object: "modal" } + ); + + Services.telemetry.clearEvents(); + }); +}); + +/** + * Tests that there is an event telemetry object "unknown" when no source is defined upon + * opening the modal. + */ +add_task(async function colorwaycloset_modal_unknown_source() { + await testInColorwayClosetModal(async document => { + // Since we already open the modal in testInColorwayClosetModal, + // do not clear telemetry events until after we verify + // the event with "unknown" source. + registerCleanupFunction(() => { + Services.telemetry.clearEvents(); + }); + + await waitForColorwaysTelemetryPromise(); + TelemetryTestUtils.assertNumberOfEvents(1, { + category: "colorways_modal", + object: "unknown", + }); + + Services.telemetry.clearEvents(); + }); +}); diff --git a/browser/components/colorways/tests/browser/head.js b/browser/components/colorways/tests/browser/head.js new file mode 100644 index 0000000000..0b8a687845 --- /dev/null +++ b/browser/components/colorways/tests/browser/head.js @@ -0,0 +1,364 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { ColorwayClosetOpener } = ChromeUtils.import( + "resource:///modules/ColorwayClosetOpener.jsm" +); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +const MOCK_COLLECTION_TEST_CARD_IMAGE_PATH = "mockCollectionPreview.avif"; +const MOCK_THEME_NAME = "Mock Theme"; +const MOCK_THEME_NAME_2 = "Mock Theme 2"; +const MOCK_THEME_DESCRIPTION = "Mock Theme Description"; +const MOCK_THEME_DESCRIPTION_2 = "Mock Theme 2 Description"; +const MOCK_THEME_FIGURE_URL = "https://www.example.com/figure.avif"; + +// fluent string ids +const EXPIRY_DATE_L10N_ID = "colorway-collection-expiry-label"; +const MOCK_COLLECTION_L10N_TITLE = "mock-collection-l10n"; +const MOCK_COLLECTION_L10N_DESCRIPTION = "mock-collection-l10n-description"; +const MOCK_COLLECTION_L10N_SHORT_DESCRIPTION = + "mock-collection-l10n-short-description"; + +// colorway theme ids +const SOFT_COLORWAY_THEME_ID = "mocktheme-soft-colorway@mozilla.org"; +const BALANCED_COLORWAY_THEME_ID = "mocktheme-balanced-colorway@mozilla.org"; +const BOLD_COLORWAY_THEME_ID = "mocktheme-bold-colorway@mozilla.org"; +const SOFT_COLORWAY_THEME_ID_2 = "mocktheme2-soft-colorway@mozilla.org"; +const BALANCED_COLORWAY_THEME_ID_2 = "mocktheme2-balanced-colorway@mozilla.org"; +const BOLD_COLORWAY_THEME_ID_2 = "mocktheme2-bold-colorway@mozilla.org"; +const NO_INTENSITY_COLORWAY_THEME_ID = "mocktheme-colorway@mozilla.org"; +const NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID = + "expiredmocktheme-colorway@mozilla.org"; + +// non-colorway theme ids +const MOCK_DARK_THEME_ID = "mocktheme-dark@mozilla.org"; + +// collections +const MOCK_COLLECTION_ID = "mock-collection"; +const TEST_COLORWAY_COLLECTION = { + id: MOCK_COLLECTION_ID, + expiry: new Date("3000-01-01"), + l10nId: { + title: MOCK_COLLECTION_L10N_TITLE, + description: MOCK_COLLECTION_L10N_DESCRIPTION, + }, + cardImagePath: MOCK_COLLECTION_TEST_CARD_IMAGE_PATH, +}; + +function installTestTheme(id) { + let name, description; + if ( + [ + SOFT_COLORWAY_THEME_ID_2, + BALANCED_COLORWAY_THEME_ID_2, + BOLD_COLORWAY_THEME_ID_2, + ].includes(id) + ) { + name = MOCK_THEME_NAME_2; + description = MOCK_THEME_DESCRIPTION_2; + } else { + name = MOCK_THEME_NAME; + description = MOCK_THEME_DESCRIPTION; + } + let xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + name, + description, + browser_specific_settings: { gecko: { id } }, + theme: + id === MOCK_DARK_THEME_ID + ? { properties: { color_scheme: "dark" } } + : {}, + }, + }); + return AddonTestUtils.promiseInstallFile(xpi); +} + +/** + * Creates stubs for BuiltInThemes + * @param {Boolean} hasActiveCollection false to create an expired collection; has default value of true to create an active collection + * @returns {Object} sinon sandbox containing all stubs for BuiltInThemes + * @see BuiltInThemes + */ +function initBuiltInThemesStubs(hasActiveCollection = true) { + info("Creating BuiltInThemes stubs"); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + info("Restoring BuiltInThemes sandbox for cleanup"); + sandbox.restore(); + }); + sandbox.stub(BuiltInThemes, "getLocalizedColorwayGroupName").callsFake(id => { + if ( + [ + SOFT_COLORWAY_THEME_ID_2, + BALANCED_COLORWAY_THEME_ID_2, + BOLD_COLORWAY_THEME_ID_2, + ].includes(id) + ) { + return MOCK_THEME_NAME_2; + } + + return MOCK_THEME_NAME; + }); + sandbox + .stub(BuiltInThemes, "getLocalizedColorwayDescription") + .callsFake(id => { + if ( + [ + SOFT_COLORWAY_THEME_ID_2, + BALANCED_COLORWAY_THEME_ID_2, + BOLD_COLORWAY_THEME_ID_2, + ].includes(id) + ) { + return MOCK_THEME_DESCRIPTION_2; + } + + return MOCK_THEME_DESCRIPTION; + }); + sandbox.stub(BuiltInThemes.builtInThemeMap, "get").callsFake(id => { + let mockThemeProperties = { + collection: MOCK_COLLECTION_ID, + figureUrl: MOCK_THEME_FIGURE_URL, + }; + if (id === NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID) { + mockThemeProperties.expiry = new Date("1970-01-01"); + } + return mockThemeProperties; + }); + + if (hasActiveCollection) { + sandbox + .stub(BuiltInThemes, "findActiveColorwayCollection") + .returns(TEST_COLORWAY_COLLECTION); + } else { + sandbox.stub(BuiltInThemes, "findActiveColorwayCollection").returns(null); + } + + sandbox + .stub(BuiltInThemes, "isColorwayFromCurrentCollection") + .callsFake(id => + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + NO_INTENSITY_COLORWAY_THEME_ID, + SOFT_COLORWAY_THEME_ID_2, + BALANCED_COLORWAY_THEME_ID_2, + BOLD_COLORWAY_THEME_ID_2, + ].includes(id) + ); + return sandbox.restore.bind(sandbox); +} + +/** + * Creates stubs for ColorwayClosetOpener + * @returns {Object} sinon sandbox containing all stubs for ColorwayClosetOpener + * @see ColorwayClosetOpener + */ +function initColorwayClosetOpenerStubs() { + info("Creating ColorwayClosetOpener stubs"); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + info("Restoring ColorwayClosetOpener sandbox for cleanup"); + sandbox.restore(); + }); + sandbox.stub(ColorwayClosetOpener, "openModal").resolves({}); + return sandbox.restore.bind(sandbox); +} + +function getColorwayClosetTestElements(document) { + return { + colorwayFigure: document.getElementById("colorway-figure"), + collectionTitle: document.getElementById("collection-title"), + expiryDateSpan: document.querySelector("#collection-expiry-date > span"), + colorwaySelector: document.querySelector("#colorway-selector"), + colorwayIntensities: document.querySelector("#colorway-intensity-radios"), + setColorwayButton: document.getElementById("set-colorway"), + cancelButton: document.getElementById("cancel"), + colorwayName: document.querySelector("#colorway-name"), + colorwayDescription: document.querySelector("#colorway-description"), + homepageResetContainer: document.getElementById("homepage-reset-container"), + homepageResetSuccessMessage: document.querySelector( + "#homepage-reset-success > span" + ), + homepageResetUndoButton: document.querySelector( + "#homepage-reset-success > button" + ), + homepageResetMessage: document.querySelector( + "#homepage-reset-prompt > span" + ), + homepageResetApplyButton: document.querySelector( + "#homepage-reset-prompt > button" + ), + }; +} + +function getColorwayClosetElementVisibility(document) { + const elements = getColorwayClosetTestElements(document); + let v = {}; + for (const k in elements) { + const isVisible = BrowserTestUtils.is_visible(elements[k]); + v[k] = { + isVisible, + isHidden: !isVisible, + }; + } + return v; +} + +async function testInColorwayClosetModal( + testMethod, + themesToInstall = [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + ], + themeToEnable = undefined +) { + const clearBuiltInThemesStubs = initBuiltInThemesStubs(); + let themesToUninstall = []; + for (let theme of themesToInstall) { + info(`Installing ${theme}`); + const { addon } = await installTestTheme(theme); + // For some tests, we might want a colorway theme already enabled + // before opening the modal. If there is such a theme, be sure + // to enable it after installing all mock themes. + if (themeToEnable && themeToEnable === theme) { + info(`Enabling ${addon.id}`); + await addon.enable(); + } else { + info(`Disabling ${addon.id}`); + await addon.disable(); + } + themesToUninstall.push(addon); + } + + const { closedPromise, dialog } = ColorwayClosetOpener.openModal(); + await dialog._dialogReady; + const document = dialog._frame.contentDocument; + let contentWindow = dialog._frame.contentWindow; + try { + await testMethod(document, contentWindow); + } finally { + // If the window is still open after the test, close it. + contentWindow = dialog._frame.contentWindow; + if (contentWindow && !contentWindow.closed) { + info("Modal is still open. Closing modal before ending test."); + document.getElementById("cancel").click(); + } else { + info("Modal is already closed"); + } + await closedPromise; + for (let addon of themesToUninstall) { + info(`Uninstalling theme ${addon.id}`); + await addon.disable(); + await addon.uninstall(); + } + clearBuiltInThemesStubs(); + // Clear any telemetry events recorded after closing the modal + Services.telemetry.clearEvents(); + } +} + +/** + * Registers mock Fluent locale strings for colorway collections. + */ +async function registerMockCollectionL10nIds() { + info("Register mock fluent locale strings"); + + let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tmpDir.append("l10n-colorwaycloset-mocks"); + + await IOUtils.makeDirectory(tmpDir.path, { ignoreExisting: true }); + await IOUtils.writeUTF8( + PathUtils.join(tmpDir.path, "mock-colorways.ftl"), + [ + `${MOCK_COLLECTION_L10N_TITLE} = Mock collection title`, + `${MOCK_COLLECTION_L10N_SHORT_DESCRIPTION} = Mock collection subheading`, + ].join("\n") + ); + + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + resProto.setSubstitution( + "l10n-colorwaycloset-mocks", + Services.io.newFileURI(tmpDir) + ); + + let mockSource = new L10nFileSource( + "colorwayscloset-mocks", + "app", + ["en-US"], + "resource://l10n-colorwaycloset-mocks/" + ); + + let l10nReg = L10nRegistry.getInstance(); + l10nReg.registerSources([mockSource]); + + registerCleanupFunction(async () => { + l10nReg.removeSources([mockSource]); + resProto.setSubstitution("l10n-colorwaycloset-mocks", null); + info(`Clearing temporary directory ${tmpDir.path}`); + await IOUtils.remove(tmpDir.path, { recursive: true, ignoreAbsent: true }); + }); + + // Confirm that the mock fluent resources are available as expected. + let bundles = l10nReg.generateBundles(["en-US"], ["mock-colorways.ftl"]); + let bundle0 = (await bundles.next()).value; + is( + bundle0.locales[0], + "en-US", + "Got the expected locale in the mock L10nFileSource" + ); + ok( + bundle0.hasMessage(MOCK_COLLECTION_L10N_TITLE), + "Got the expected l10n id in the mock L10nFileSource" + ); +} + +/** + * Waits for an addon to be enabled. + * @param {String} addonId the string id of an addon + * @returns {Promise} resolved promise when the the specified addon is enabled + */ +function waitForAddonEnabled(addonId) { + return new Promise(resolve => { + let listener = { + onEnabled(enabledAddon) { + if (enabledAddon.id == addonId) { + AddonManager.removeAddonListener(listener); + info(`Addon ${addonId} enabled. Removing listener and resolving.`); + resolve(); + } + }, + }; + info(`Adding onEnabled listener for "${addonId}`); + AddonManager.addAddonListener(listener); + }); +} + +/** + * Waits for a Colorways telemetry event to trigger. + * @returns {Promise} promise from BrowserTestUtils.waitForCondition + */ +async function waitForColorwaysTelemetryPromise() { + return BrowserTestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + return colorwayEvents && colorwayEvents.length; + }, "Waiting for Colorways events ping"); +} |