diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
commit | da4c7e7ed675c3bf405668739c3012d140856109 (patch) | |
tree | cdd868dba063fecba609a1d819de271f0d51b23e /browser/components | |
parent | Adding upstream version 125.0.3. (diff) | |
download | firefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip |
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components')
702 files changed, 20741 insertions, 9490 deletions
diff --git a/browser/components/BrowserContentHandler.sys.mjs b/browser/components/BrowserContentHandler.sys.mjs index ca7cf4d2c4..247f33c8b0 100644 --- a/browser/components/BrowserContentHandler.sys.mjs +++ b/browser/components/BrowserContentHandler.sys.mjs @@ -438,7 +438,7 @@ function openBrowserWindow( }); } -function openPreferences(cmdLine, extraArgs) { +function openPreferences(cmdLine) { openBrowserWindow(cmdLine, lazy.gSystemPrincipal, "about:preferences"); } @@ -1244,24 +1244,12 @@ nsDefaultCommandLineHandler.prototype = { async function handleNotification() { let { tagWasHandled } = await alertService.handleWindowsTag(tag); - // If the tag was not handled via callback, then the notification was - // from a prior instance of the application and we need to handle - // fallback behavior. - if (!tagWasHandled) { - console.info( - `Completing Windows notification (tag=${JSON.stringify( - tag - )}, notificationData=${notificationData})` + try { + notificationData = JSON.parse(notificationData); + } catch (e) { + console.error( + `Failed to parse (notificationData=${notificationData}) for Windows notification (tag=${tag})` ); - try { - notificationData = JSON.parse(notificationData); - } catch (e) { - console.error( - `Completing Windows notification (tag=${JSON.stringify( - tag - )}, failed to parse (notificationData=${notificationData})` - ); - } } // This is awkward: the relaunch data set by the caller is _wrapped_ @@ -1275,11 +1263,7 @@ nsDefaultCommandLineHandler.prototype = { ); } catch (e) { console.error( - `Completing Windows notification (tag=${JSON.stringify( - tag - )}, failed to parse (opaqueRelaunchData=${ - notificationData.opaqueRelaunchData - })` + `Failed to parse (opaqueRelaunchData=${notificationData.opaqueRelaunchData}) for Windows notification (tag=${tag})` ); } } @@ -1298,9 +1282,16 @@ nsDefaultCommandLineHandler.prototype = { // window to perform the action in. let winForAction; - if (notificationData?.launchUrl && !opaqueRelaunchData) { - // Unprivileged Web Notifications contain a launch URL and are handled - // slightly differently than privileged notifications with actions. + if ( + !tagWasHandled && + notificationData?.launchUrl && + !opaqueRelaunchData + ) { + // Unprivileged Web Notifications contain a launch URL and are + // handled slightly differently than privileged notifications with + // actions. If the tag was not handled, then the notification was + // from a prior instance of the application and we need to handle + // fallback behavior. let { uri, principal } = resolveURIInternal( cmdLine, notificationData.launchUrl @@ -1347,6 +1338,14 @@ nsDefaultCommandLineHandler.prototype = { }); } + // Note: at time of writing `opaqueRelaunchData` was only used by the + // Messaging System; if present it could be inferred that the message + // originated from the Messaging System. The Messaging System did not + // act on Windows 8 style notification callbacks, so there was no risk + // of duplicating behavior. If a non-Messaging System consumer is + // modified to populate `opaqueRelaunchData` or the Messaging System + // modified to use the callback directly, we will need to revisit + // this assumption. if (opaqueRelaunchData && winForAction) { // Without dispatch, `OPEN_URL` with `where: "tab"` does not work on relaunch. Services.tm.dispatchToMainThread(() => { diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index f4ea0c87a3..52f4a77d82 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -27,9 +27,10 @@ ChromeUtils.defineESModuleGetters(lazy, { BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + ContentRelevancyManager: + "resource://gre/modules/ContentRelevancyManager.sys.mjs", ContextualIdentityService: "resource://gre/modules/ContextualIdentityService.sys.mjs", - Corroborate: "resource://gre/modules/Corroborate.sys.mjs", DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs", DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", Discovery: "resource:///modules/Discovery.sys.mjs", @@ -80,8 +81,7 @@ ChromeUtils.defineESModuleGetters(lazy, { Sanitizer: "resource:///modules/Sanitizer.sys.mjs", SaveToPocket: "chrome://pocket/content/SaveToPocket.sys.mjs", ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", - SearchSERPDomainToCategoriesMap: - "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", @@ -223,6 +223,22 @@ let JSPROCESSACTORS = { * available at https://firefox-source-docs.mozilla.org/dom/ipc/jsactors.html */ let JSWINDOWACTORS = { + Megalist: { + parent: { + esModuleURI: "resource://gre/actors/MegalistParent.sys.mjs", + }, + child: { + esModuleURI: "resource://gre/actors/MegalistChild.sys.mjs", + events: { + DOMContentLoaded: {}, + }, + }, + includeChrome: true, + matches: ["chrome://global/content/megalist/megalist.html"], + allFrames: true, + enablePreference: "browser.megalist.enabled", + }, + AboutLogins: { parent: { esModuleURI: "resource:///actors/AboutLoginsParent.sys.mjs", @@ -2111,7 +2127,7 @@ BrowserGlue.prototype = { () => lazy.BrowserUsageTelemetry.uninit(), () => lazy.SearchSERPTelemetry.uninit(), - () => lazy.SearchSERPDomainToCategoriesMap.uninit(), + () => lazy.SearchSERPCategorization.uninit(), () => lazy.Interactions.uninit(), () => lazy.PageDataService.uninit(), () => lazy.PageThumbs.uninit(), @@ -2341,7 +2357,7 @@ BrowserGlue.prototype = { _badgeIcon(); } - lazy.RemoteSettings(STUDY_ADDON_COLLECTION_KEY).on("sync", async event => { + lazy.RemoteSettings(STUDY_ADDON_COLLECTION_KEY).on("sync", async () => { Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, true); }); @@ -2436,7 +2452,7 @@ BrowserGlue.prototype = { lazy.Sanitizer.onStartup(); this._maybeShowRestoreSessionInfoBar(); this._scheduleStartupIdleTasks(); - this._lateTasksIdleObserver = (idleService, topic, data) => { + this._lateTasksIdleObserver = (idleService, topic) => { if (topic == "idle") { idleService.removeIdleObserver( this._lateTasksIdleObserver, @@ -2662,7 +2678,19 @@ BrowserGlue.prototype = { AppConstants.platform == "win") && Services.prefs.getBoolPref("browser.firefoxbridge.enabled", false), task: async () => { - await lazy.FirefoxBridgeExtensionUtils.ensureRegistered(); + let profileService = Cc[ + "@mozilla.org/toolkit/profile-service;1" + ].getService(Ci.nsIToolkitProfileService); + if ( + profileService.defaultProfile && + profileService.currentProfile == profileService.defaultProfile + ) { + await lazy.FirefoxBridgeExtensionUtils.ensureRegistered(); + } else { + lazy.log.debug( + "FirefoxBridgeExtensionUtils failed to register due to non-default current profile." + ); + } }, }, @@ -3082,9 +3110,16 @@ BrowserGlue.prototype = { }, { - name: "SearchSERPDomainToCategoriesMap.init", + name: "SearchSERPCategorization.init", task: () => { - lazy.SearchSERPDomainToCategoriesMap.init().catch(console.error); + lazy.SearchSERPCategorization.init(); + }, + }, + + { + name: "ContentRelevancyManager.init", + task: () => { + lazy.ContentRelevancyManager.init(); }, }, @@ -3193,12 +3228,6 @@ BrowserGlue.prototype = { lazy.RemoteSecuritySettings.init(); }, - function CorroborateInit() { - if (Services.prefs.getBoolPref("corroborator.enabled", false)) { - lazy.Corroborate.init().catch(console.error); - } - }, - function BrowserUsageTelemetryReportProfileCount() { lazy.BrowserUsageTelemetry.reportProfileCount(); }, @@ -3699,7 +3728,7 @@ BrowserGlue.prototype = { "account-connection-connected", ]); - let clickCallback = (subject, topic, data) => { + let clickCallback = (subject, topic) => { if (topic != "alertclickcallback") { return; } @@ -3740,7 +3769,7 @@ BrowserGlue.prototype = { _migrateUI() { // Use an increasing number to keep track of the current migration state. // Completely unrelated to the current Firefox release number. - const UI_VERSION = 143; + const UI_VERSION = 144; const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; if (!Services.prefs.prefHasUserValue("browser.migration.version")) { @@ -4374,6 +4403,23 @@ BrowserGlue.prototype = { } } + if (currentUIVersion < 144) { + // TerminatorTelemetry was removed in bug 1879136. Before it was removed, + // the ShutdownDuration.json file would be written to disk at shutdown + // so that the next launch of the browser could read it in and send + // shutdown performance measurements. + // + // Unfortunately, this mechanism and its measurements were fairly + // unreliable, so they were removed. + for (const filename of [ + "ShutdownDuration.json", + "ShutdownDuration.json.tmp", + ]) { + const filePath = PathUtils.join(PathUtils.profileDir, filename); + IOUtils.remove(filePath, { ignoreAbsent: true }).catch(console.error); + } + } + // Update the migration version. Services.prefs.setIntPref("browser.migration.version", UI_VERSION); }, @@ -4462,14 +4508,6 @@ BrowserGlue.prototype = { // Check the default branch as enterprise policies can set prefs there. const defaultPrefs = Services.prefs.getDefaultBranch(""); - if ( - !defaultPrefs.getBoolPref( - "browser.messaging-system.whatsNewPanel.enabled", - true - ) - ) { - return "no-whatsNew"; - } if (!defaultPrefs.getBoolPref("browser.aboutwelcome.enabled", true)) { return "no-welcome"; } @@ -4712,7 +4750,7 @@ BrowserGlue.prototype = { } const title = await lazy.accountsL10n.formatValue(titleL10nId); - const clickCallback = (obsSubject, obsTopic, obsData) => { + const clickCallback = (obsSubject, obsTopic) => { if (obsTopic == "alertclickcallback") { win.gBrowser.selectedTab = firstTab; } @@ -4751,7 +4789,7 @@ BrowserGlue.prototype = { tab = win.gBrowser.addWebTab(url); } tab.attention = true; - let clickCallback = (subject, topic, data) => { + let clickCallback = (subject, topic) => { if (topic != "alertclickcallback") { return; } @@ -4780,7 +4818,7 @@ BrowserGlue.prototype = { : { id: "account-connection-connected-with-noname" }, ]); - let clickCallback = async (subject, topic, data) => { + let clickCallback = async (subject, topic) => { if (topic != "alertclickcallback") { return; } @@ -4815,7 +4853,7 @@ BrowserGlue.prototype = { "account-connection-disconnected", ]); - let clickCallback = (subject, topic, data) => { + let clickCallback = (subject, topic) => { if (topic != "alertclickcallback") { return; } @@ -4870,7 +4908,7 @@ BrowserGlue.prototype = { const TOGGLE_ENABLED_PREF = "media.videocontrols.picture-in-picture.video-toggle.enabled"; - const observe = (subject, topic, data) => { + const observe = (subject, topic) => { const enabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF, false); Services.telemetry.scalarSet("pictureinpicture.toggle_enabled", enabled); @@ -6475,11 +6513,11 @@ export var AboutHomeStartupCache = { /** nsICacheEntryOpenCallback **/ - onCacheEntryCheck(aEntry) { + onCacheEntryCheck() { return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED; }, - onCacheEntryAvailable(aEntry, aNew, aResult) { + onCacheEntryAvailable(aEntry) { this.log.trace("Cache entry is available."); this._cacheEntry = aEntry; diff --git a/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js index 5ab03f9867..86e754084b 100644 --- a/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js +++ b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js @@ -35,7 +35,7 @@ add_task(async function () { createLazyBrowser: true, }); - Assert.equal(lazyTab.linkedPanel, "", "Tab is lazy"); + Assert.equal(lazyTab.linkedPanel, null, "Tab is lazy"); let tabLoaded = new Promise(resolve => { gBrowser.addTabsProgressListener({ async onLocationChange(aBrowser) { diff --git a/browser/components/aboutlogins/tests/browser/head.js b/browser/components/aboutlogins/tests/browser/head.js index 2aec0e632a..82d3cf2062 100644 --- a/browser/components/aboutlogins/tests/browser/head.js +++ b/browser/components/aboutlogins/tests/browser/head.js @@ -131,7 +131,7 @@ add_setup(async function setup_head() { return; } if (msg.errorMessage.includes("Can't find profile directory.")) { - // Ignore error messages for no profile found in old XULStore.jsm + // Ignore error messages for no profile found in old XULStore.sys.mjs return; } if (msg.errorMessage.includes("Error reading typed URL history")) { diff --git a/browser/components/aboutwelcome/.eslintrc.js b/browser/components/aboutwelcome/.eslintrc.js index 1168a8e840..0b8e1cc676 100644 --- a/browser/components/aboutwelcome/.eslintrc.js +++ b/browser/components/aboutwelcome/.eslintrc.js @@ -80,8 +80,6 @@ module.exports = { }, ], rules: { - "fetch-options/no-fetch-credentials": "error", - "react/jsx-boolean-value": ["error", "always"], "react/jsx-key": "error", "react/jsx-no-bind": [ diff --git a/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs b/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs index 7b32161e3b..258eff36ef 100644 --- a/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs +++ b/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs @@ -70,7 +70,7 @@ class AboutWelcomeObserver { this.win.addEventListener("unload", this.onWindowClose, { once: true }); } - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { switch (aTopic) { case "quit-application": this.terminateReason = AWTerminate.APP_SHUT_DOWN; diff --git a/browser/components/aboutwelcome/content-src/aboutwelcome.scss b/browser/components/aboutwelcome/content-src/aboutwelcome.scss index 9174fe2439..07bfcd2c96 100644 --- a/browser/components/aboutwelcome/content-src/aboutwelcome.scss +++ b/browser/components/aboutwelcome/content-src/aboutwelcome.scss @@ -1139,6 +1139,38 @@ html { &[no-rdm] { width: 800px; + + &[reverse-split] { + flex-direction: row-reverse; + + .section-main { + .main-content { + border-radius: inherit; + } + + margin: auto; + margin-inline-end: 0; + border-radius: 8px 0 0 8px; + + &:dir(rtl) { + border-radius: 0 8px 8px 0; + margin: auto; + margin-inline-end: 0; + } + } + + .section-secondary { + margin: auto; + margin-inline-start: 0; + border-radius: 0 8px 8px 0; + + &:dir(rtl) { + border-radius: 8px 0 0 8px; + margin: auto; + margin-inline-start: 0; + } + } + } } } @@ -1372,6 +1404,107 @@ html { outline: 2px solid var(--in-content-primary-button-background); } + // newtab wallpaper specific styles + &.wallpaper { + justify-content: center; + gap: 10px; + + &:hover, &:focus-within { + outline: none; + } + + .theme { + flex: unset; + width: unset; + transition: var(--transition); + + &:has(.input:focus) { + outline: 2px solid var(--in-content-primary-button-background); + outline-offset: 2px; + } + + .icon { + width: 116px; + height: 86px; + border-radius: 8px; + box-shadow: 0 1px 2px 0 #3A394433; + + &:hover { + filter: brightness(45%); + } + + // dark theme wallpapers + &.dark-landscape { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/dark-landscape.avif'); + } + + &.dark-beach { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/dark-beach.avif'); + } + + &.dark-color { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/dark-color.avif'); + } + + &.dark-mountain { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/dark-mountain.avif'); + } + + &.dark-panda { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/dark-panda.avif'); + } + + &.dark-sky { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/dark-sky.avif'); + } + + // light theme wallpapers + &.light-beach { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/light-beach.avif'); + } + + &.light-color { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/light-color.avif'); + } + + &.light-landscape { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/light-landscape.avif'); + } + + &.light-mountain { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/light-mountain.avif'); + } + + &.light-panda { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/light-panda.avif'); + } + + &.light-sky { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/light-sky.avif'); + } + } + } + + .dark { + display: none; + } + + .text { + display: none; + } + + @media (prefers-color-scheme: dark) { + .light { + display: none; + } + + .dark { + display: block; + } + } + + } + .theme { align-items: center; display: flex; @@ -1405,13 +1538,17 @@ html { transform: scaleX(-1); } - &:focus, + &:focus-visible, &:active, &.selected { outline: 2px solid var(--in-content-primary-button-background); outline-offset: 2px; } + &.selected { + outline-color: var(--color-accent-primary-active); + } + &.light { background-image: url('resource://builtin-themes/light/icon.svg'); } diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx index 034055bf3d..3ccbd71f40 100644 --- a/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx +++ b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx @@ -463,9 +463,21 @@ export class WelcomeScreen extends React.PureComponent { action.theme === "<event>" ? event.currentTarget.value : this.props.initialTheme || action.theme; - this.props.setActiveTheme(themeToUse); - window.AWSelectTheme(themeToUse); + if (props.content.tiles?.category?.type === "wallpaper") { + const theme = themeToUse.split("-")?.[1]; + let actionWallpaper = { ...props.content.tiles.category.action }; + actionWallpaper.data.actions.forEach(async wpAction => { + if (wpAction.data.pref.name?.includes("dark")) { + wpAction.data.pref.value = `dark-${theme}`; + } else { + wpAction.data.pref.value = `light-${theme}`; + } + await AboutWelcomeUtils.handleUserAction(actionWallpaper); + }); + } else { + window.AWSelectTheme(themeToUse); + } } // If the action has persistActiveTheme: true, we set the initial theme to the currently active theme diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx index 59771e4e48..b6e1ffa6b5 100644 --- a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx +++ b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx @@ -570,32 +570,39 @@ export class ProtonScreen extends React.PureComponent { </div> ) : null} - <div className="main-content-inner"> - <div className={`welcome-text ${content.title_style || ""}`}> - {content.title ? this.renderTitle(content) : null} + <div + className="main-content-inner" + style={{ + justifyContent: content.split_content_justify_content, + }} + > + {content.title || content.subtitle ? ( + <div className={`welcome-text ${content.title_style || ""}`}> + {content.title ? this.renderTitle(content) : null} - {content.subtitle ? ( - <Localized text={content.subtitle}> - <h2 - data-l10n-args={JSON.stringify({ - "addon-name": this.props.addonName, - ...this.props.appAndSystemLocaleInfo?.displayNames, - })} - aria-flowto={ - this.props.messageId?.includes("FEATURE_TOUR") - ? "steps" - : "" - } + {content.subtitle ? ( + <Localized text={content.subtitle}> + <h2 + data-l10n-args={JSON.stringify({ + "addon-name": this.props.addonName, + ...this.props.appAndSystemLocaleInfo?.displayNames, + })} + aria-flowto={ + this.props.messageId?.includes("FEATURE_TOUR") + ? "steps" + : "" + } + /> + </Localized> + ) : null} + {content.cta_paragraph ? ( + <CTAParagraph + content={content.cta_paragraph} + handleAction={this.props.handleAction} /> - </Localized> - ) : null} - {content.cta_paragraph ? ( - <CTAParagraph - content={content.cta_paragraph} - handleAction={this.props.handleAction} - /> - ) : null} - </div> + ) : null} + </div> + ) : null} {content.video_container ? ( <OnboardingVideo content={content.video_container} diff --git a/browser/components/aboutwelcome/content-src/components/Themes.jsx b/browser/components/aboutwelcome/content-src/components/Themes.jsx index 0ee986f982..e430ecf3aa 100644 --- a/browser/components/aboutwelcome/content-src/components/Themes.jsx +++ b/browser/components/aboutwelcome/content-src/components/Themes.jsx @@ -6,28 +6,29 @@ import React from "react"; import { Localized } from "./MSLocalized"; export const Themes = props => { + const category = props.content.tiles?.category?.type; return ( <div className="tiles-theme-container"> <div> - <fieldset className="tiles-theme-section"> + <fieldset className={`tiles-theme-section ${category}`}> <Localized text={props.content.subtitle}> <legend className="sr-only" /> </Localized> {props.content.tiles.data.map( - ({ theme, label, tooltip, description }) => ( + ({ theme, label, tooltip, description, type }) => ( <Localized key={theme + label} text={typeof tooltip === "object" ? tooltip : {}} > {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - <label className="theme" title={theme + label}> + <label className={`theme ${type}`} title={theme + label}> <Localized text={typeof description === "object" ? description : {}} > <input type="radio" value={theme} - name="theme" + name={category === "wallpaper" ? theme : "theme"} checked={theme === props.activeTheme} className="sr-only input" onClick={props.handleAction} diff --git a/browser/components/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/aboutwelcome/content/aboutwelcome.bundle.js index 0d96257677..11a398e960 100644 --- a/browser/components/aboutwelcome/content/aboutwelcome.bundle.js +++ b/browser/components/aboutwelcome/content/aboutwelcome.bundle.js @@ -560,7 +560,22 @@ class WelcomeScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo if (action.theme) { let themeToUse = action.theme === "<event>" ? event.currentTarget.value : this.props.initialTheme || action.theme; this.props.setActiveTheme(themeToUse); - window.AWSelectTheme(themeToUse); + if (props.content.tiles?.category?.type === "wallpaper") { + const theme = themeToUse.split("-")?.[1]; + let actionWallpaper = { + ...props.content.tiles.category.action + }; + actionWallpaper.data.actions.forEach(async wpAction => { + if (wpAction.data.pref.name?.includes("dark")) { + wpAction.data.pref.value = `dark-${theme}`; + } else { + wpAction.data.pref.value = `light-${theme}`; + } + await _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_2__.AboutWelcomeUtils.handleUserAction(actionWallpaper); + }); + } else { + window.AWSelectTheme(themeToUse); + } } // If the action has persistActiveTheme: true, we set the initial theme to the currently active theme @@ -1214,8 +1229,11 @@ class ProtonScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCom alt: "", role: "presentation" })) : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { - className: "main-content-inner" - }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { + className: "main-content-inner", + style: { + justifyContent: content.split_content_justify_content + } + }, content.title || content.subtitle ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { className: `welcome-text ${content.title_style || ""}` }, content.title ? this.renderTitle(content) : null, content.subtitle ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { text: content.subtitle @@ -1228,7 +1246,7 @@ class ProtonScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCom })) : null, content.cta_paragraph ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_CTAParagraph__WEBPACK_IMPORTED_MODULE_8__.CTAParagraph, { content: content.cta_paragraph, handleAction: this.props.handleAction - }) : null), content.video_container ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_OnboardingVideo__WEBPACK_IMPORTED_MODULE_10__.OnboardingVideo, { + }) : null) : null, content.video_container ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_OnboardingVideo__WEBPACK_IMPORTED_MODULE_10__.OnboardingVideo, { content: content.video_container, handleAction: this.props.handleAction }) : null, this.renderContentTiles(), this.renderLanguageSwitcher(), content.above_button_content ? this.renderOrderedContent(content.above_button_content) : null, !hideStepsIndicator && aboveButtonStepsIndicator ? this.renderStepsIndicator() : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, { @@ -1448,10 +1466,11 @@ __webpack_require__.r(__webpack_exports__); const Themes = props => { + const category = props.content.tiles?.category?.type; return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { className: "tiles-theme-container" }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("fieldset", { - className: "tiles-theme-section" + className: `tiles-theme-section ${category}` }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { text: props.content.subtitle }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("legend", { @@ -1460,19 +1479,20 @@ const Themes = props => { theme, label, tooltip, - description + description, + type }) => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { key: theme + label, text: typeof tooltip === "object" ? tooltip : {} }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("label", { - className: "theme", + className: `theme ${type}`, title: theme + label }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { text: typeof description === "object" ? description : {} }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("input", { type: "radio", value: theme, - name: "theme", + name: category === "wallpaper" ? theme : "theme", checked: theme === props.activeTheme, className: "sr-only input", onClick: props.handleAction diff --git a/browser/components/aboutwelcome/content/aboutwelcome.css b/browser/components/aboutwelcome/content/aboutwelcome.css index aa0445e0ef..4e86b29e5d 100644 --- a/browser/components/aboutwelcome/content/aboutwelcome.css +++ b/browser/components/aboutwelcome/content/aboutwelcome.css @@ -1896,6 +1896,32 @@ html { .onboardingContainer .screen[pos=split][no-rdm] { width: 800px; } + .onboardingContainer .screen[pos=split][no-rdm][reverse-split] { + flex-direction: row-reverse; + } + .onboardingContainer .screen[pos=split][no-rdm][reverse-split] .section-main { + margin: auto; + margin-inline-end: 0; + border-radius: 8px 0 0 8px; + } + .onboardingContainer .screen[pos=split][no-rdm][reverse-split] .section-main .main-content { + border-radius: inherit; + } + .onboardingContainer .screen[pos=split][no-rdm][reverse-split] .section-main:dir(rtl) { + border-radius: 0 8px 8px 0; + margin: auto; + margin-inline-end: 0; + } + .onboardingContainer .screen[pos=split][no-rdm][reverse-split] .section-secondary { + margin: auto; + margin-inline-start: 0; + border-radius: 0 8px 8px 0; + } + .onboardingContainer .screen[pos=split][no-rdm][reverse-split] .section-secondary:dir(rtl) { + border-radius: 8px 0 0 8px; + margin: auto; + margin-inline-start: 0; + } } @media only screen and (height <= 650px) and (800px <= width <= 990px) { .onboardingContainer .screen[pos=split] .section-main .secondary-cta.top { @@ -2091,6 +2117,81 @@ html { border-radius: 8px; outline: 2px solid var(--in-content-primary-button-background); } +.onboardingContainer .tiles-theme-section.wallpaper { + justify-content: center; + gap: 10px; +} +.onboardingContainer .tiles-theme-section.wallpaper:hover, .onboardingContainer .tiles-theme-section.wallpaper:focus-within { + outline: none; +} +.onboardingContainer .tiles-theme-section.wallpaper .theme { + flex: unset; + width: unset; + transition: var(--transition); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme:has(.input:focus) { + outline: 2px solid var(--in-content-primary-button-background); + outline-offset: 2px; +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon { + width: 116px; + height: 86px; + border-radius: 8px; + box-shadow: 0 1px 2px 0 rgba(58, 57, 68, 0.2); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon:hover { + filter: brightness(45%); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.dark-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-landscape.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.dark-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-beach.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.dark-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-color.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.dark-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-mountain.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.dark-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-panda.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.dark-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-sky.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.light-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-beach.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.light-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-color.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.light-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-landscape.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.light-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-mountain.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.light-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-panda.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .theme .icon.light-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-sky.avif"); +} +.onboardingContainer .tiles-theme-section.wallpaper .dark { + display: none; +} +.onboardingContainer .tiles-theme-section.wallpaper .text { + display: none; +} +@media (prefers-color-scheme: dark) { + .onboardingContainer .tiles-theme-section.wallpaper .light { + display: none; + } + .onboardingContainer .tiles-theme-section.wallpaper .dark { + display: block; + } +} .onboardingContainer .tiles-theme-section .theme { align-items: center; display: flex; @@ -2121,10 +2222,13 @@ html { .onboardingContainer .tiles-theme-section .theme .icon:dir(rtl) { transform: scaleX(-1); } -.onboardingContainer .tiles-theme-section .theme .icon:focus, .onboardingContainer .tiles-theme-section .theme .icon:active, .onboardingContainer .tiles-theme-section .theme .icon.selected { +.onboardingContainer .tiles-theme-section .theme .icon:focus-visible, .onboardingContainer .tiles-theme-section .theme .icon:active, .onboardingContainer .tiles-theme-section .theme .icon.selected { outline: 2px solid var(--in-content-primary-button-background); outline-offset: 2px; } +.onboardingContainer .tiles-theme-section .theme .icon.selected { + outline-color: var(--color-accent-primary-active); +} .onboardingContainer .tiles-theme-section .theme .icon.light { background-image: url("resource://builtin-themes/light/icon.svg"); } diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js index 3081688a0c..f3bea5b499 100644 --- a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js @@ -249,7 +249,7 @@ add_task(async function test_aboutwelcome_with_title_styles() { { "font-weight": "276", "font-size": "36px", - animation: "50s linear 0s infinite normal none running shine", + animation: "50s linear infinite shine", "letter-spacing": "normal", } ); diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_toolbar_button.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_toolbar_button.js index c9180ddf2d..308c27a427 100644 --- a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_toolbar_button.js +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_toolbar_button.js @@ -32,7 +32,7 @@ add_task(async function test_add_and_remove_toolbar_button() { }); // Open newtab let win = await BrowserTestUtils.openNewBrowserWindow(); - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); ok(win, "browser exists"); // Try to add the button. It shouldn't add because the pref is false await AWToolbarButton.maybeAddSetupButton(); diff --git a/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx index 9b452d5c6b..ed0260bf30 100644 --- a/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx +++ b/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx @@ -671,4 +671,27 @@ describe("MultiStageAboutWelcomeProton module", () => { assert.isTrue(wrapper.find("migration-wizard").exists()); }); }); + + describe("Custom main content inner custom justify content", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + position: "split", + split_content_justify_content: "flex-start", + }, + }; + + it("should render split screen with custom justify-content", async () => { + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find("main").prop("pos"), "split"); + assert.exists(wrapper.find(".main-content-inner")); + assert.ok( + wrapper + .find(".main-content-inner") + .prop("style") + .justifyContent.includes("flex-start") + ); + }); + }); }); diff --git a/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx index b4593a45f3..2fb897125d 100644 --- a/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx +++ b/browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx @@ -383,6 +383,72 @@ describe("MultiStageAboutWelcome module", () => { ); }); }); + + describe("Wallpaper screen", () => { + let WALLPAPER_SCREEN_PROPS; + beforeEach(() => { + WALLPAPER_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + tiles: { + type: "theme", + category: { + type: "wallpaper", + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "test-dark", + }, + }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "test-light", + }, + }, + }, + ], + }, + }, + }, + action: { + theme: "<event>", + }, + data: [ + { + theme: "mountain", + type: "light", + }, + ], + }, + primary_button: { + action: {}, + label: "test button", + }, + }, + navigate: sandbox.stub(), + setActiveTheme: sandbox.stub(), + }; + sandbox.stub(AboutWelcomeUtils, "handleUserAction").resolves(); + }); + it("should handle wallpaper click", () => { + const wrapper = mount(<WelcomeScreen {...WALLPAPER_SCREEN_PROPS} />); + const wallpaperOptions = wrapper.find( + ".tiles-theme-section .theme input[name='mountain']" + ); + wallpaperOptions.simulate("click"); + assert.calledTwice(AboutWelcomeUtils.handleUserAction); + }); + }); + describe("#handleAction", () => { let SCREEN_PROPS; let TEST_ACTION; diff --git a/browser/components/aboutwelcome/tests/unit/unit-entry.js b/browser/components/aboutwelcome/tests/unit/unit-entry.js index fb70eeb843..3da6964c53 100644 --- a/browser/components/aboutwelcome/tests/unit/unit-entry.js +++ b/browser/components/aboutwelcome/tests/unit/unit-entry.js @@ -97,8 +97,8 @@ const TEST_GLOBAL = { JSWindowActorParent, JSWindowActorChild, AboutReaderParent: { - addMessageListener: (messageName, listener) => {}, - removeMessageListener: (messageName, listener) => {}, + addMessageListener: (_messageName, _listener) => {}, + removeMessageListener: (_messageName, _listener) => {}, }, AboutWelcomeTelemetry: class { submitGleanPingForPing() {} @@ -281,8 +281,8 @@ const TEST_GLOBAL = { }, dump() {}, EveryWindow: { - registerCallback: (id, init, uninit) => {}, - unregisterCallback: id => {}, + registerCallback: (_id, _init, _uninit) => {}, + unregisterCallback: _id => {}, }, setTimeout: window.setTimeout.bind(window), clearTimeout: window.clearTimeout.bind(window), @@ -402,7 +402,7 @@ const TEST_GLOBAL = { }, urlFormatter: { formatURL: str => str, formatURLPref: str => str }, mm: { - addMessageListener: (msg, cb) => this.receiveMessage(), + addMessageListener: (_msg, _cb) => this.receiveMessage(), removeMessageListener() {}, }, obs: { @@ -412,7 +412,7 @@ const TEST_GLOBAL = { }, telemetry: { setEventRecordingEnabled: () => {}, - recordEvent: eventDetails => {}, + recordEvent: _eventDetails => {}, scalarSet: () => {}, keyedScalarAdd: () => {}, }, @@ -570,7 +570,7 @@ const TEST_GLOBAL = { finish: () => {}, }, Sampling: { - ratioSample(seed, ratios) { + ratioSample(_seed, _ratios) { return Promise.resolve(0); }, }, diff --git a/browser/components/asrouter/.eslintrc.js b/browser/components/asrouter/.eslintrc.js index ef5bc81b68..b2a647e42d 100644 --- a/browser/components/asrouter/.eslintrc.js +++ b/browser/components/asrouter/.eslintrc.js @@ -63,8 +63,6 @@ module.exports = { }, ], rules: { - "fetch-options/no-fetch-credentials": "error", - "react/jsx-boolean-value": ["error", "always"], "react/jsx-key": "error", "react/jsx-no-bind": [ diff --git a/browser/components/asrouter/actors/ASRouterChild.sys.mjs b/browser/components/asrouter/actors/ASRouterChild.sys.mjs index 2096d92bb3..95f625e2b5 100644 --- a/browser/components/asrouter/actors/ASRouterChild.sys.mjs +++ b/browser/components/asrouter/actors/ASRouterChild.sys.mjs @@ -11,9 +11,7 @@ // eslint-disable-next-line mozilla/use-static-import const { MESSAGE_TYPE_LIST, MESSAGE_TYPE_HASH: msg } = - ChromeUtils.importESModule( - "resource:///modules/asrouter/ActorConstants.sys.mjs" - ); + ChromeUtils.importESModule("resource:///modules/asrouter/ActorConstants.mjs"); const VALID_TYPES = new Set(MESSAGE_TYPE_LIST); @@ -103,8 +101,6 @@ export class ASRouterChild extends JSWindowActorChild { case msg.DISABLE_PROVIDER: case msg.ENABLE_PROVIDER: case msg.EXPIRE_QUERY_CACHE: - case msg.FORCE_WHATSNEW_PANEL: - case msg.CLOSE_WHATSNEW_PANEL: case msg.FORCE_PRIVATE_BROWSING_WINDOW: case msg.IMPRESSION: case msg.RESET_PROVIDER_PREF: diff --git a/browser/components/asrouter/bin/import-rollouts.js b/browser/components/asrouter/bin/import-rollouts.js index d29a31a068..bb5c17d9ae 100644 --- a/browser/components/asrouter/bin/import-rollouts.js +++ b/browser/components/asrouter/bin/import-rollouts.js @@ -126,10 +126,6 @@ async function getMessageValidators(skipValidation) { "./content-src/templates/OnboardingMessage/UpdateAction.schema.json", { common: true } ), - whatsnew_panel_message: await getValidator( - "./content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json", - { common: true } - ), feature_callout: await getValidator( // For now, Feature Callout and Spotlight share a common schema "./content-src/templates/OnboardingMessage/Spotlight.schema.json", diff --git a/browser/components/asrouter/content-src/asrouter-utils.mjs b/browser/components/asrouter/content-src/asrouter-utils.mjs index 989d864e71..3789158547 100644 --- a/browser/components/asrouter/content-src/asrouter-utils.mjs +++ b/browser/components/asrouter/content-src/asrouter-utils.mjs @@ -2,10 +2,8 @@ * 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/. */ -// eslint-disable-next-line mozilla/reject-import-system-module-from-non-system -import { MESSAGE_TYPE_HASH as msg } from "../modules/ActorConstants.sys.mjs"; -// eslint-disable-next-line mozilla/reject-import-system-module-from-non-system -import { actionCreators as ac } from "../../newtab/common/Actions.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "../modules/ActorConstants.mjs"; +import { actionCreators as ac } from "../../newtab/common/Actions.mjs"; export const ASRouterUtils = { addListener(listener) { diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx index befce707ef..32d1614307 100644 --- a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -15,6 +15,18 @@ const Row = props => ( </tr> ); +// Convert a UTF-8 string to a string in which only one byte of each +// 16-bit unit is occupied. This is necessary to comply with `btoa` API constraints. +export function toBinary(string) { + const codeUnits = new Uint16Array(string.length); + for (let i = 0; i < codeUnits.length; i++) { + codeUnits[i] = string.charCodeAt(i); + } + return btoa( + String.fromCharCode(...Array.from(new Uint8Array(codeUnits.buffer))) + ); +} + function relativeTime(timestamp) { if (!timestamp) { return ""; @@ -531,7 +543,9 @@ export class ASRouterAdminInner extends React.PureComponent { {aboutMessagePreviewSupported ? ( <CopyButton transformer={text => - `about:messagepreview?json=${encodeURIComponent(btoa(text))}` + `about:messagepreview?json=${encodeURIComponent( + toBinary(text) + )}` } label="Share" copiedLabel="Copied!" diff --git a/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json b/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json index 9de01052f7..5fe86f9617 100644 --- a/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json +++ b/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json @@ -10,7 +10,9 @@ "const": "multi" } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/MultiMessage" @@ -68,7 +70,9 @@ "type": "object" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": true }, "requireInteraction": { @@ -116,24 +120,37 @@ "type": "object" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": true } }, - "required": ["action", "title"], + "required": [ + "action", + "title" + ], "additionalProperties": true } } }, "additionalProperties": true, - "required": ["title", "body"] + "required": [ + "title", + "body" + ] }, "template": { "type": "string", "const": "toast_notification" } }, - "required": ["content", "targeting", "template", "trigger"], + "required": [ + "content", + "targeting", + "template", + "trigger" + ], "additionalProperties": true }, "Message": { @@ -154,7 +171,9 @@ "template": { "type": "string", "description": "Which messaging template this message is using.", - "enum": ["toast_notification"] + "enum": [ + "toast_notification" + ] }, "frequency": { "type": "object", @@ -184,7 +203,10 @@ "maximum": 100 } }, - "required": ["period", "cap"] + "required": [ + "period", + "cap" + ] } } } @@ -224,7 +246,9 @@ } } }, - "required": ["id"] + "required": [ + "id" + ] }, "provider": { "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", @@ -233,8 +257,14 @@ }, "additionalProperties": true, "dependentRequired": { - "content": ["id", "template"], - "template": ["id", "content"] + "content": [ + "id", + "template" + ], + "template": [ + "id", + "content" + ] } }, "localizedText": { @@ -245,7 +275,9 @@ "type": "string" } }, - "required": ["string_id"] + "required": [ + "string_id" + ] }, "localizableText": { "description": "Either a raw string or an object containing the string_id of the localized text", @@ -272,10 +304,14 @@ "properties": { "template": { "type": "string", - "enum": ["toast_notification"] + "enum": [ + "toast_notification" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/ToastNotification" @@ -299,7 +335,10 @@ } } }, - "required": ["template", "messages"] + "required": [ + "template", + "messages" + ] } } } diff --git a/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json b/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json index fbabb109f8..dd4ce4776d 100644 --- a/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json +++ b/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json @@ -10,7 +10,9 @@ "const": "multi" } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/MultiMessage" @@ -41,7 +43,9 @@ "layout": { "type": "string", "description": "Describes how content should be displayed.", - "enum": ["chiclet_open_url"] + "enum": [ + "chiclet_open_url" + ] }, "bucket_id": { "type": "string", @@ -66,11 +70,17 @@ "where": { "description": "Should it open in a new tab or the current tab", "type": "string", - "enum": ["current", "tabshifted"] + "enum": [ + "current", + "tabshifted" + ] } }, "additionalProperties": true, - "required": ["url", "where"] + "required": [ + "url", + "where" + ] } }, "additionalProperties": true, @@ -87,7 +97,10 @@ "const": "cfr_urlbar_chiclet" } }, - "required": ["targeting", "trigger"] + "required": [ + "targeting", + "trigger" + ] }, "ExtensionDoorhanger": { "$schema": "https://json-schema.org/draft/2019-09/schema", @@ -162,10 +175,14 @@ "description": "Text for button tooltip used to provide information about the doorhanger." } }, - "required": ["tooltiptext"] + "required": [ + "tooltiptext" + ] } }, - "required": ["attributes"] + "required": [ + "attributes" + ] }, { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText" @@ -185,7 +202,10 @@ "learn_more": { "type": "string", "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.", - "examples": ["extensionpromotions", "extensionrecommendations"] + "examples": [ + "extensionpromotions", + "extensionrecommendations" + ] }, "heading_text": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", @@ -236,7 +256,12 @@ "description": "Link that offers more information related to the addon." } }, - "required": ["title", "author", "icon", "amo_url"] + "required": [ + "title", + "author", + "icon", + "amo_url" + ] }, "text": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", @@ -255,7 +280,9 @@ } } }, - "required": ["steps"] + "required": [ + "steps" + ] }, "buttons": { "description": "The label and functionality for the buttons in the pop-over.", @@ -281,11 +308,16 @@ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." } }, - "required": ["accesskey"], + "required": [ + "accesskey" + ], "description": "Button attributes." } }, - "required": ["value", "attributes"] + "required": [ + "value", + "attributes" + ] }, { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText" @@ -341,11 +373,16 @@ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." } }, - "required": ["accesskey"], + "required": [ + "accesskey" + ], "description": "Button attributes." } }, - "required": ["value", "attributes"] + "required": [ + "value", + "attributes" + ] }, { "properties": { @@ -360,7 +397,9 @@ ] } }, - "required": ["string_id"] + "required": [ + "string_id" + ] } ], "description": "Id of localized string or message override." @@ -417,16 +456,25 @@ } }, "then": { - "required": ["category", "notification_text"] + "required": [ + "category", + "notification_text" + ] } }, "template": { "type": "string", - "enum": ["cfr_doorhanger", "milestone_message"] + "enum": [ + "cfr_doorhanger", + "milestone_message" + ] } }, "additionalProperties": true, - "required": ["targeting", "trigger"], + "required": [ + "targeting", + "trigger" + ], "$defs": { "plainText": { "description": "Plain text (no HTML allowed)", @@ -457,7 +505,10 @@ "type": { "type": "string", "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).", - "enum": ["global", "tab"] + "enum": [ + "global", + "tab" + ] }, "text": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", @@ -497,7 +548,9 @@ "type": "object" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": true }, "supportPage": { @@ -505,13 +558,19 @@ "description": "A page title on SUMO to link to" } }, - "required": ["label", "action"], + "required": [ + "label", + "action" + ], "additionalProperties": true } } }, "additionalProperties": true, - "required": ["text", "buttons"] + "required": [ + "text", + "buttons" + ] }, "template": { "type": "string", @@ -519,7 +578,10 @@ } }, "additionalProperties": true, - "required": ["targeting", "trigger"], + "required": [ + "targeting", + "trigger" + ], "$defs": { "plainText": { "description": "Plain text (no HTML allowed)", @@ -587,12 +649,22 @@ "promoType": { "type": "string", "description": "Promo type used to determine if promo should show to a given user", - "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"] + "enum": [ + "FOCUS", + "VPN", + "PIN", + "COOKIE_BANNERS", + "OTHER" + ] }, "promoSectionStyle": { "type": "string", "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.", - "enum": ["top", "below-search", "bottom"] + "enum": [ + "top", + "below-search", + "bottom" + ] }, "promoTitle": { "type": "string", @@ -624,16 +696,23 @@ "type": "object" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": true } }, - "required": ["action"] + "required": [ + "action" + ] }, "promoLinkType": { "type": "string", "description": "Type of promo link type. Possible values: link, button. Default is link.", - "enum": ["link", "button"] + "enum": [ + "link", + "button" + ] }, "promoImageLarge": { "type": "string", @@ -655,10 +734,14 @@ "const": true } }, - "required": ["promoEnabled"] + "required": [ + "promoEnabled" + ] }, "then": { - "required": ["promoButton"] + "required": [ + "promoButton" + ] } }, { @@ -668,20 +751,28 @@ "const": true } }, - "required": ["infoEnabled"] + "required": [ + "infoEnabled" + ] }, "then": { - "required": ["infoLinkText"], + "required": [ + "infoLinkText" + ], "if": { "properties": { "infoTitleEnabled": { "const": true } }, - "required": ["infoTitleEnabled"] + "required": [ + "infoTitleEnabled" + ] }, "then": { - "required": ["infoTitle"] + "required": [ + "infoTitle" + ] } } } @@ -693,7 +784,9 @@ } }, "additionalProperties": true, - "required": ["targeting"] + "required": [ + "targeting" + ] }, "Spotlight": { "$schema": "https://json-schema.org/draft/2019-09/schema", @@ -763,11 +856,16 @@ "template": { "type": "string", "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog", - "enum": ["spotlight", "feature_callout"] + "enum": [ + "spotlight", + "feature_callout" + ] } }, "additionalProperties": true, - "required": ["targeting"] + "required": [ + "targeting" + ] }, "ToastNotification": { "$schema": "https://json-schema.org/draft/2019-09/schema", @@ -818,7 +916,9 @@ "type": "object" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": true }, "requireInteraction": { @@ -866,24 +966,37 @@ "type": "object" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": true } }, - "required": ["action", "title"], + "required": [ + "action", + "title" + ], "additionalProperties": true } } }, "additionalProperties": true, - "required": ["title", "body"] + "required": [ + "title", + "body" + ] }, "template": { "type": "string", "const": "toast_notification" } }, - "required": ["content", "targeting", "template", "trigger"], + "required": [ + "content", + "targeting", + "template", + "trigger" + ], "additionalProperties": true }, "ToolbarBadgeMessage": { @@ -912,7 +1025,9 @@ } }, "additionalProperties": true, - "required": ["id"], + "required": [ + "id" + ], "description": "Optional action to take in addition to showing the notification" }, "delay": { @@ -925,7 +1040,9 @@ } }, "additionalProperties": true, - "required": ["target"] + "required": [ + "target" + ] }, "template": { "type": "string", @@ -933,7 +1050,9 @@ } }, "additionalProperties": true, - "required": ["targeting"] + "required": [ + "targeting" + ] }, "UpdateAction": { "$schema": "https://json-schema.org/draft/2019-09/schema", @@ -973,101 +1092,25 @@ }, "additionalProperties": true, "description": "Optional action to take in addition to showing the notification", - "required": ["id", "data"] - } - }, - "additionalProperties": true, - "required": ["action"] - }, - "template": { - "type": "string", - "const": "update_action" - } - }, - "required": ["targeting"] - }, - "WhatsNewMessage": { - "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "file:///WhatsNewMessage.schema.json", - "title": "WhatsNewMessage", - "description": "A template for the messages that appear in the What's New panel.", - "allOf": [ - { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" - } - ], - "type": "object", - "properties": { - "content": { - "type": "object", - "properties": { - "layout": { - "description": "Different message layouts", - "enum": ["tracking-protections"] - }, - "bucket_id": { - "type": "string", - "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." - }, - "published_date": { - "type": "integer", - "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." - }, - "title": { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", - "description": "Id of localized string or message override of What's New message title" - }, - "subtitle": { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", - "description": "Id of localized string or message override of What's New message subtitle" - }, - "body": { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", - "description": "Id of localized string or message override of What's New message body" - }, - "link_text": { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", - "description": "(optional) Id of localized string or message override of What's New message link text" - }, - "cta_url": { - "description": "Target URL for the What's New message.", - "type": "string", - "format": "moz-url-format" - }, - "cta_type": { - "description": "Type of url open action", - "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"] - }, - "cta_where": { - "description": "How to open the cta: new window, tab, focused, unfocused.", - "enum": ["current", "tabshifted", "tab", "save", "window"] - }, - "icon_url": { - "description": "(optional) URL for the What's New message icon.", - "type": "string", - "format": "uri" - }, - "icon_alt": { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", - "description": "Alt text for image." + "required": [ + "id", + "data" + ] } }, "additionalProperties": true, "required": [ - "published_date", - "title", - "body", - "cta_url", - "bucket_id" + "action" ] }, "template": { "type": "string", - "const": "whatsnew_panel_message" + "const": "update_action" } }, - "required": ["order"], - "additionalProperties": true + "required": [ + "targeting" + ] }, "Message": { "type": "object", @@ -1097,8 +1140,7 @@ "feature_callout", "toast_notification", "toolbar_badge", - "update_action", - "whatsnew_panel_message" + "update_action" ] }, "frequency": { @@ -1129,7 +1171,10 @@ "maximum": 100 } }, - "required": ["period", "cap"] + "required": [ + "period", + "cap" + ] } } } @@ -1169,7 +1214,9 @@ } } }, - "required": ["id"] + "required": [ + "id" + ] }, "provider": { "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", @@ -1178,8 +1225,14 @@ }, "additionalProperties": true, "dependentRequired": { - "content": ["id", "template"], - "template": ["id", "content"] + "content": [ + "id", + "template" + ], + "template": [ + "id", + "content" + ] } }, "localizedText": { @@ -1190,7 +1243,9 @@ "type": "string" } }, - "required": ["string_id"] + "required": [ + "string_id" + ] }, "localizableText": { "description": "Either a raw string or an object containing the string_id of the localized text", @@ -1217,10 +1272,14 @@ "properties": { "template": { "type": "string", - "enum": ["cfr_urlbar_chiclet"] + "enum": [ + "cfr_urlbar_chiclet" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/CFRUrlbarChiclet" @@ -1232,10 +1291,15 @@ "properties": { "template": { "type": "string", - "enum": ["cfr_doorhanger", "milestone_message"] + "enum": [ + "cfr_doorhanger", + "milestone_message" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ExtensionDoorhanger" @@ -1247,10 +1311,14 @@ "properties": { "template": { "type": "string", - "enum": ["infobar"] + "enum": [ + "infobar" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/InfoBar" @@ -1262,10 +1330,14 @@ "properties": { "template": { "type": "string", - "enum": ["pb_newtab"] + "enum": [ + "pb_newtab" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/NewtabPromoMessage" @@ -1277,10 +1349,15 @@ "properties": { "template": { "type": "string", - "enum": ["spotlight", "feature_callout"] + "enum": [ + "spotlight", + "feature_callout" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Spotlight" @@ -1292,10 +1369,14 @@ "properties": { "template": { "type": "string", - "enum": ["toast_notification"] + "enum": [ + "toast_notification" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ToastNotification" @@ -1307,10 +1388,14 @@ "properties": { "template": { "type": "string", - "enum": ["toolbar_badge"] + "enum": [ + "toolbar_badge" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ToolbarBadgeMessage" @@ -1322,29 +1407,18 @@ "properties": { "template": { "type": "string", - "enum": ["update_action"] + "enum": [ + "update_action" + ] } }, - "required": ["template"] + "required": [ + "template" + ] }, "then": { "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/UpdateAction" } - }, - { - "if": { - "type": "object", - "properties": { - "template": { - "type": "string", - "enum": ["whatsnew_panel_message"] - } - }, - "required": ["template"] - }, - "then": { - "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/WhatsNewMessage" - } } ] }, @@ -1364,7 +1438,10 @@ } } }, - "required": ["template", "messages"] + "required": [ + "template", + "messages" + ] } } } diff --git a/browser/components/asrouter/content-src/schemas/make-schemas.py b/browser/components/asrouter/content-src/schemas/make-schemas.py index f66490f23a..1f677cab28 100755 --- a/browser/components/asrouter/content-src/schemas/make-schemas.py +++ b/browser/components/asrouter/content-src/schemas/make-schemas.py @@ -83,9 +83,6 @@ SCHEMAS = [ "UpdateAction": ( SCHEMA_DIR / "OnboardingMessage" / "UpdateAction.schema.json" ), - "WhatsNewMessage": ( - SCHEMA_DIR / "OnboardingMessage" / "WhatsNewMessage.schema.json" - ), }, bundle_common=True, test_corpus={ diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json deleted file mode 100644 index 26e795d068..0000000000 --- a/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "file:///WhatsNewMessage.schema.json", - "title": "WhatsNewMessage", - "description": "A template for the messages that appear in the What's New panel.", - "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], - "type": "object", - "properties": { - "content": { - "type": "object", - "properties": { - "layout": { - "description": "Different message layouts", - "enum": ["tracking-protections"] - }, - "bucket_id": { - "type": "string", - "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." - }, - "published_date": { - "type": "integer", - "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." - }, - "title": { - "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", - "description": "Id of localized string or message override of What's New message title" - }, - "subtitle": { - "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", - "description": "Id of localized string or message override of What's New message subtitle" - }, - "body": { - "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", - "description": "Id of localized string or message override of What's New message body" - }, - "link_text": { - "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", - "description": "(optional) Id of localized string or message override of What's New message link text" - }, - "cta_url": { - "description": "Target URL for the What's New message.", - "type": "string", - "format": "moz-url-format" - }, - "cta_type": { - "description": "Type of url open action", - "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"] - }, - "cta_where": { - "description": "How to open the cta: new window, tab, focused, unfocused.", - "enum": ["current", "tabshifted", "tab", "save", "window"] - }, - "icon_url": { - "description": "(optional) URL for the What's New message icon.", - "type": "string", - "format": "uri" - }, - "icon_alt": { - "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", - "description": "Alt text for image." - } - }, - "additionalProperties": true, - "required": ["published_date", "title", "body", "cta_url", "bucket_id"] - }, - "template": { - "type": "string", - "const": "whatsnew_panel_message" - } - }, - "required": ["order"], - "additionalProperties": true -} diff --git a/browser/components/asrouter/content/asrouter-admin.bundle.js b/browser/components/asrouter/content/asrouter-admin.bundle.js index b38d551a17..e5a1e6dd6a 100644 --- a/browser/components/asrouter/content/asrouter-admin.bundle.js +++ b/browser/components/asrouter/content/asrouter-admin.bundle.js @@ -16,15 +16,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export */ __webpack_require__.d(__webpack_exports__, { /* harmony export */ ASRouterUtils: () => (/* binding */ ASRouterUtils) /* harmony export */ }); -/* harmony import */ var _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); -/* harmony import */ var _newtab_common_Actions_sys_mjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3); +/* harmony import */ var _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); +/* harmony import */ var _newtab_common_Actions_mjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3); /* 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/. */ -// eslint-disable-next-line mozilla/reject-import-system-module-from-non-system -// eslint-disable-next-line mozilla/reject-import-system-module-from-non-system const ASRouterUtils = { @@ -46,54 +44,54 @@ const ASRouterUtils = { }, blockById(id, options) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.BLOCK_MESSAGE_BY_ID, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.BLOCK_MESSAGE_BY_ID, data: { id, ...options }, }); }, modifyMessageJson(content) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.MODIFY_MESSAGE_JSON, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.MODIFY_MESSAGE_JSON, data: { content }, }); }, executeAction(button_action) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.USER_ACTION, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.USER_ACTION, data: button_action, }); }, unblockById(id) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.UNBLOCK_MESSAGE_BY_ID, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.UNBLOCK_MESSAGE_BY_ID, data: { id }, }); }, blockBundle(bundle) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.BLOCK_BUNDLE, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.BLOCK_BUNDLE, data: { bundle }, }); }, unblockBundle(bundle) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.UNBLOCK_BUNDLE, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.UNBLOCK_BUNDLE, data: { bundle }, }); }, overrideMessage(id) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.OVERRIDE_MESSAGE, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.OVERRIDE_MESSAGE, data: { id }, }); }, editState(key, value) { return ASRouterUtils.sendMessage({ - type: _modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.EDIT_STATE, + type: _modules_ActorConstants_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.EDIT_STATE, data: { [key]: value }, }); }, sendTelemetry(ping) { - return ASRouterUtils.sendMessage(_newtab_common_Actions_sys_mjs__WEBPACK_IMPORTED_MODULE_1__.actionCreators.ASRouterUserEvent(ping)); + return ASRouterUtils.sendMessage(_newtab_common_Actions_mjs__WEBPACK_IMPORTED_MODULE_1__.actionCreators.ASRouterUserEvent(ping)); }, getPreviewEndpoint() { return null; @@ -124,7 +122,6 @@ const MESSAGE_TYPE_LIST = [ "PBNEWTAB_MESSAGE_REQUEST", "DOORHANGER_TELEMETRY", "TOOLBAR_BADGE_TELEMETRY", - "TOOLBAR_PANEL_TELEMETRY", "MOMENTS_PAGE_TELEMETRY", "INFOBAR_TELEMETRY", "SPOTLIGHT_TELEMETRY", @@ -142,9 +139,7 @@ const MESSAGE_TYPE_LIST = [ "EVALUATE_JEXL_EXPRESSION", "EXPIRE_QUERY_CACHE", "FORCE_ATTRIBUTION", - "FORCE_WHATSNEW_PANEL", "FORCE_PRIVATE_BROWSING_WINDOW", - "CLOSE_WHATSNEW_PANEL", "OVERRIDE_MESSAGE", "MODIFY_MESSAGE_JSON", "RESET_PROVIDER_PREF", @@ -181,6 +176,8 @@ __webpack_require__.r(__webpack_exports__); * 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 accessed from both content and system scopes. + const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; const PRELOAD_MESSAGE_TYPE = "ActivityStream:PreloadedBrowser"; @@ -337,6 +334,7 @@ for (const type of [ "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", + "WALLPAPERS_SET", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { @@ -550,8 +548,11 @@ function DiscoveryStreamLoadedContent( return importContext === UI_CODE ? AlsoToMain(action) : action; } -function SetPref(name, value, importContext = globalImportContext) { - const action = { type: actionTypes.SET_PREF, data: { name, value } }; +function SetPref(prefName, value, importContext = globalImportContext) { + const action = { + type: actionTypes.SET_PREF, + data: { name: prefName, value }, + }; return importContext === UI_CODE ? AlsoToMain(action) : action; } @@ -961,7 +962,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony export */ ToggleMessageJSON: () => (/* binding */ ToggleMessageJSON), /* harmony export */ TogglePrefCheckbox: () => (/* binding */ TogglePrefCheckbox), /* harmony export */ ToggleStoryButton: () => (/* binding */ ToggleStoryButton), -/* harmony export */ renderASRouterAdmin: () => (/* binding */ renderASRouterAdmin) +/* harmony export */ renderASRouterAdmin: () => (/* binding */ renderASRouterAdmin), +/* harmony export */ toBinary: () => (/* binding */ toBinary) /* harmony export */ }); /* harmony import */ var _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); @@ -985,6 +987,16 @@ function _extends() { _extends = Object.assign ? Object.assign.bind() : function const Row = props => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", _extends({ className: "message-item" }, props), props.children); + +// Convert a UTF-8 string to a string in which only one byte of each +// 16-bit unit is occupied. This is necessary to comply with `btoa` API constraints. +function toBinary(string) { + const codeUnits = new Uint16Array(string.length); + for (let i = 0; i < codeUnits.length; i++) { + codeUnits[i] = string.charCodeAt(i); + } + return btoa(String.fromCharCode(...Array.from(new Uint8Array(codeUnits.buffer)))); +} function relativeTime(timestamp) { if (!timestamp) { return ""; @@ -1427,7 +1439,7 @@ class ASRouterAdminInner extends (react__WEBPACK_IMPORTED_MODULE_1___default().P className: "button modify", onClick: () => this.modifyJson(msg) }, "Modify"), aboutMessagePreviewSupported ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(_CopyButton__WEBPACK_IMPORTED_MODULE_4__.CopyButton, { - transformer: text => `about:messagepreview?json=${encodeURIComponent(btoa(text))}`, + transformer: text => `about:messagepreview?json=${encodeURIComponent(toBinary(text))}`, label: "Share", copiedLabel: "Copied!", inputSelector: `#${msg.id}-textarea`, diff --git a/browser/components/asrouter/docs/targeting-attributes.md b/browser/components/asrouter/docs/targeting-attributes.md index 89c5a6b6c6..b0049a4f1b 100644 --- a/browser/components/asrouter/docs/targeting-attributes.md +++ b/browser/components/asrouter/docs/targeting-attributes.md @@ -44,7 +44,6 @@ Please note that some targeting attributes require stricter controls on the tele * [isFxASignedIn](#isFxASignedIn) * [isMajorUpgrade](#ismajorupgrade) * [isRTAMO](#isrtamo) -* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled) * [launchOnLoginEnabled](#launchonloginenabled) * [locale](#locale) * [localeLanguageCode](#localelanguagecode) @@ -630,16 +629,6 @@ Boolean pref that gets set the first time the user opens the FxA toolbar panel declare const hasAccessedFxAPanel: boolean; ``` -### `isWhatsNewPanelEnabled` - -Boolean pref that controls if the What's New panel feature is enabled - -#### Definition - -```ts -declare const isWhatsNewPanelEnabled: boolean; -``` - ### `totalBlockedCount` Total number of events from the content blocking database diff --git a/browser/components/asrouter/modules/ASRouter.sys.mjs b/browser/components/asrouter/modules/ASRouter.sys.mjs index e46c57f685..b36a9023e1 100644 --- a/browser/components/asrouter/modules/ASRouter.sys.mjs +++ b/browser/components/asrouter/modules/ASRouter.sys.mjs @@ -55,7 +55,6 @@ ChromeUtils.defineESModuleGetters(lazy, { Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs", ToastNotification: "resource:///modules/asrouter/ToastNotification.sys.mjs", ToolbarBadgeHub: "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs", - ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs", }); XPCOMUtils.defineLazyServiceGetters(lazy, { @@ -67,7 +66,7 @@ ChromeUtils.defineLazyGetter(lazy, "log", () => { ); return new Logger("ASRouter"); }); -import { actionCreators as ac } from "resource://activity-stream/common/Actions.sys.mjs"; +import { actionCreators as ac } from "resource://activity-stream/common/Actions.mjs"; import { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } from "resource:///modules/asrouter/MessagingExperimentConstants.sys.mjs"; import { CFRMessageProvider } from "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"; import { OnboardingMessageProvider } from "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"; @@ -620,7 +619,6 @@ export class _ASRouter { this._onLocaleChanged = this._onLocaleChanged.bind(this); this.isUnblockedMessage = this.isUnblockedMessage.bind(this); this.unblockAll = this.unblockAll.bind(this); - this.forceWNPanel = this.forceWNPanel.bind(this); this._onExperimentEnrollmentsUpdated = this._onExperimentEnrollmentsUpdated.bind(this); this.forcePBWindow = this.forcePBWindow.bind(this); @@ -995,10 +993,6 @@ export class _ASRouter { unblockMessageById: this.unblockMessageById, sendTelemetry: this.sendTelemetry, }); - lazy.ToolbarPanelHub.init(this.waitForInitialized, { - getMessages: this.handleMessageRequest, - sendTelemetry: this.sendTelemetry, - }); lazy.MomentsPageHub.init(this.waitForInitialized, { handleMessageRequest: this.handleMessageRequest, addImpression: this.addImpression, @@ -1055,7 +1049,6 @@ export class _ASRouter { lazy.ASRouterPreferences.removeListener(this.onPrefChange); lazy.ASRouterPreferences.uninit(); - lazy.ToolbarPanelHub.uninit(); lazy.ToolbarBadgeHub.uninit(); lazy.MomentsPageHub.uninit(); @@ -1309,16 +1302,6 @@ export class _ASRouter { return true; } - async _extraTemplateStrings(originalMessage) { - let extraTemplateStrings; - let localProvider = this._findProvider(originalMessage.provider); - if (localProvider && localProvider.getExtraAttributes) { - extraTemplateStrings = await localProvider.getExtraAttributes(); - } - - return extraTemplateStrings; - } - _findProvider(providerID) { return this._localProviders[ this.state.providers.find(i => i.id === providerID).localProvider @@ -1346,11 +1329,6 @@ export class _ASRouter { } switch (message.template) { - case "whatsnew_panel_message": - if (force) { - lazy.ToolbarPanelHub.forceShowMessage(browser, message); - } - break; case "cfr_doorhanger": case "milestone_message": if (force) { @@ -2005,29 +1983,6 @@ export class _ASRouter { ); } - async forceWNPanel(browser) { - let win = browser.ownerGlobal; - await lazy.ToolbarPanelHub.enableToolbarButton(); - - win.PanelUI.showSubView( - "PanelUI-whatsNew", - win.document.getElementById("whats-new-menu-button") - ); - - let panel = win.document.getElementById("customizationui-widget-panel"); - // Set the attribute to keep the panel open - panel.setAttribute("noautohide", true); - } - - async closeWNPanel(browser) { - let win = browser.ownerGlobal; - let panel = win.document.getElementById("customizationui-widget-panel"); - // Set the attribute to allow the panel to close - panel.setAttribute("noautohide", false); - // Removing the button is enough to close the panel. - await lazy.ToolbarPanelHub._hideToolbarButton(win); - } - async _onExperimentEnrollmentsUpdated() { const experimentProvider = this.state.providers.find( p => p.id === "messaging-experiments" diff --git a/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs index c2f5fcd884..8aa4d7dbc9 100644 --- a/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs +++ b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs @@ -4,7 +4,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs"; -import { MESSAGE_TYPE_HASH as msg } from "resource:///modules/asrouter/ActorConstants.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "resource:///modules/asrouter/ActorConstants.mjs"; export class ASRouterParentProcessMessageHandler { constructor({ @@ -27,7 +27,6 @@ export class ASRouterParentProcessMessageHandler { switch (type) { case msg.INFOBAR_TELEMETRY: case msg.TOOLBAR_BADGE_TELEMETRY: - case msg.TOOLBAR_PANEL_TELEMETRY: case msg.MOMENTS_PAGE_TELEMETRY: case msg.DOORHANGER_TELEMETRY: case msg.SPOTLIGHT_TELEMETRY: @@ -128,12 +127,6 @@ export class ASRouterParentProcessMessageHandler { case msg.FORCE_PRIVATE_BROWSING_WINDOW: { return this._router.forcePBWindow(browser, data.message); } - case msg.FORCE_WHATSNEW_PANEL: { - return this._router.forceWNPanel(browser); - } - case msg.CLOSE_WHATSNEW_PANEL: { - return this._router.closeWNPanel(browser); - } case msg.MODIFY_MESSAGE_JSON: { return this._router.routeCFRMessage(data.content, browser, data, true); } diff --git a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs index d76b303fc6..9773eda270 100644 --- a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs +++ b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs @@ -74,12 +74,6 @@ XPCOMUtils.defineLazyPreferenceGetter( ); XPCOMUtils.defineLazyPreferenceGetter( lazy, - "isWhatsNewPanelEnabled", - "browser.messaging-system.whatsNewPanel.enabled", - false -); -XPCOMUtils.defineLazyPreferenceGetter( - lazy, "hasAccessedFxAPanel", "identity.fxaccounts.toolbar.accessed", false @@ -704,9 +698,6 @@ const TargetingGetters = { get hasAccessedFxAPanel() { return lazy.hasAccessedFxAPanel; }, - get isWhatsNewPanelEnabled() { - return lazy.isWhatsNewPanelEnabled; - }, get userPrefs() { return { cfrFeatures: lazy.cfrFeaturesUserPref, diff --git a/browser/components/asrouter/modules/ActorConstants.sys.mjs b/browser/components/asrouter/modules/ActorConstants.mjs index 4c996552ab..c1c18e006e 100644 --- a/browser/components/asrouter/modules/ActorConstants.sys.mjs +++ b/browser/components/asrouter/modules/ActorConstants.mjs @@ -12,7 +12,6 @@ export const MESSAGE_TYPE_LIST = [ "PBNEWTAB_MESSAGE_REQUEST", "DOORHANGER_TELEMETRY", "TOOLBAR_BADGE_TELEMETRY", - "TOOLBAR_PANEL_TELEMETRY", "MOMENTS_PAGE_TELEMETRY", "INFOBAR_TELEMETRY", "SPOTLIGHT_TELEMETRY", @@ -30,9 +29,7 @@ export const MESSAGE_TYPE_LIST = [ "EVALUATE_JEXL_EXPRESSION", "EXPIRE_QUERY_CACHE", "FORCE_ATTRIBUTION", - "FORCE_WHATSNEW_PANEL", "FORCE_PRIVATE_BROWSING_WINDOW", - "CLOSE_WHATSNEW_PANEL", "OVERRIDE_MESSAGE", "MODIFY_MESSAGE_JSON", "RESET_PROVIDER_PREF", diff --git a/browser/components/asrouter/modules/MomentsPageHub.sys.mjs b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs index 84fee3b517..3a59e9d450 100644 --- a/browser/components/asrouter/modules/MomentsPageHub.sys.mjs +++ b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs @@ -165,7 +165,7 @@ export class _MomentsPageHub { } /** - * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate + * MomentsPageHub - singleton instance of _MomentsPageHub that can initiate * message requests and render messages. */ export const MomentsPageHub = new _MomentsPageHub(); diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs index ceded6b755..3cfbbb3f34 100644 --- a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs +++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs @@ -1210,6 +1210,106 @@ const BASE_MESSAGES = () => [ id: "defaultBrowserCheck", }, }, + { + id: "SET_DEFAULT_BROWSER_GUIDANCE_NOTIFICATION_WIN10", + template: "toast_notification", + content: { + title: { + string_id: "default-browser-guidance-notification-title", + }, + body: { + string_id: + "default-browser-guidance-notification-body-instruction-win10", + }, + launch_action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/win-set-firefox-default-browser", + where: "tabshifted", + }, + }, + requireInteraction: true, + actions: [ + { + action: "info-page", + title: { + string_id: "default-browser-guidance-notification-info-page", + }, + launch_action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/win-set-firefox-default-browser", + where: "tabshifted", + }, + }, + }, + { + action: "dismiss", + title: { + string_id: "default-browser-guidance-notification-dismiss", + }, + windowsSystemActivationType: true, + }, + ], + tag: "set-default-guidance-notification", + }, + // Both Windows 10 and 11 return `os.windowsVersion == 10.0`. We limit to + // only Windows 10 with `os.windowsBuildNumber < 22000`. We need this due to + // Windows 10 and 11 having substantively different UX for Windows Settings. + targeting: + "os.isWindows && os.windowsVersion >= 10.0 && os.windowsBuildNumber < 22000", + trigger: { id: "deeplinkedToWindowsSettingsUI" }, + }, + { + id: "SET_DEFAULT_BROWSER_GUIDANCE_NOTIFICATION_WIN11", + template: "toast_notification", + content: { + title: { + string_id: "default-browser-guidance-notification-title", + }, + body: { + string_id: + "default-browser-guidance-notification-body-instruction-win11", + }, + launch_action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/win-set-firefox-default-browser", + where: "tabshifted", + }, + }, + requireInteraction: true, + actions: [ + { + action: "info-page", + title: { + string_id: "default-browser-guidance-notification-info-page", + }, + launch_action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/win-set-firefox-default-browser", + where: "tabshifted", + }, + }, + }, + { + action: "dismiss", + title: { + string_id: "default-browser-guidance-notification-dismiss", + }, + windowsSystemActivationType: true, + }, + ], + tag: "set-default-guidance-notification", + }, + // Both Windows 10 and 11 return `os.windowsVersion == 10.0`. We limit to + // only Windows 11 with `os.windowsBuildNumber >= 22000`. We need this due to + // Windows 10 and 11 having substantively different UX for Windows Settings. + targeting: + "os.isWindows && os.windowsVersion >= 10.0 && os.windowsBuildNumber >= 22000", + trigger: { id: "deeplinkedToWindowsSettingsUI" }, + }, ]; // Eventually, move Feature Callout messages to their own provider diff --git a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs index 7a7ff1e1fc..5180e2e6a2 100644 --- a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs +++ b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs @@ -20,134 +20,6 @@ const MESSAGES = () => [ trigger: { id: "momentsUpdate" }, }, { - id: "WHATS_NEW_FINGERPRINTER_COUNTER_ALT", - template: "whatsnew_panel_message", - order: 6, - content: { - bucket_id: "WHATS_NEW_72", - published_date: 1574776601000, - title: "Title", - icon_url: - "chrome://activity-stream/content/data/content/assets/protection-report-icon.png", - icon_alt: { string_id: "cfr-badge-reader-label-newfeature" }, - body: "Message body", - link_text: "Click here", - cta_url: "about:blank", - cta_type: "OPEN_PROTECTION_REPORT", - }, - targeting: `firefoxVersion >= 72`, - trigger: { id: "whatsNewPanelOpened" }, - }, - { - id: "WHATS_NEW_70_1", - template: "whatsnew_panel_message", - order: 3, - content: { - bucket_id: "WHATS_NEW_70_1", - published_date: 1560969794394, - title: "Protection Is Our Focus", - icon_url: - "chrome://activity-stream/content/data/content/assets/whatsnew-send-icon.png", - icon_alt: "Firefox Send Logo", - body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", - cta_url: "https://blog.mozilla.org/", - cta_type: "OPEN_URL", - }, - targeting: `firefoxVersion > 69`, - trigger: { id: "whatsNewPanelOpened" }, - }, - { - id: "WHATS_NEW_70_2", - template: "whatsnew_panel_message", - order: 1, - content: { - bucket_id: "WHATS_NEW_70_1", - published_date: 1560969794394, - title: "Another thing new in Firefox 70", - body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", - link_text: "Learn more on our blog", - cta_url: "https://blog.mozilla.org/", - cta_type: "OPEN_URL", - }, - targeting: `firefoxVersion > 69`, - trigger: { id: "whatsNewPanelOpened" }, - }, - { - id: "WHATS_NEW_SEARCH_SHORTCUTS_84", - template: "whatsnew_panel_message", - order: 2, - content: { - bucket_id: "WHATS_NEW_SEARCH_SHORTCUTS_84", - published_date: 1560969794394, - title: "Title", - icon_url: "chrome://global/skin/icons/check.svg", - icon_alt: "", - body: "Message content", - cta_url: - "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/search-shortcuts", - cta_type: "OPEN_URL", - link_text: "Click here", - }, - targeting: "firefoxVersion >= 84", - trigger: { - id: "whatsNewPanelOpened", - }, - }, - { - id: "WHATS_NEW_PIONEER_82", - template: "whatsnew_panel_message", - order: 1, - content: { - bucket_id: "WHATS_NEW_PIONEER_82", - published_date: 1603152000000, - title: "Put your data to work for a better internet", - body: "Contribute your data to Mozilla's Pioneer program to help researchers understand pressing technology issues like misinformation, data privacy, and ethical AI.", - cta_url: "about:blank", - cta_where: "tab", - cta_type: "OPEN_ABOUT_PAGE", - link_text: "Join Pioneer", - }, - targeting: "firefoxVersion >= 82", - trigger: { - id: "whatsNewPanelOpened", - }, - }, - { - id: "WHATS_NEW_MEDIA_SESSION_82", - template: "whatsnew_panel_message", - order: 3, - content: { - bucket_id: "WHATS_NEW_MEDIA_SESSION_82", - published_date: 1603152000000, - title: "Title", - body: "Message content", - cta_url: - "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/media-keyboard-control", - cta_type: "OPEN_URL", - link_text: "Click here", - }, - targeting: "firefoxVersion >= 82", - trigger: { - id: "whatsNewPanelOpened", - }, - }, - { - id: "WHATS_NEW_69_1", - template: "whatsnew_panel_message", - order: 1, - content: { - bucket_id: "WHATS_NEW_69_1", - published_date: 1557346235089, - title: "Something new in Firefox 69", - body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", - link_text: "Learn more on our blog", - cta_url: "https://blog.mozilla.org/", - cta_type: "OPEN_URL", - }, - targeting: `firefoxVersion > 68`, - trigger: { id: "whatsNewPanelOpened" }, - }, - { id: "PERSONALIZED_CFR_MESSAGE", template: "cfr_doorhanger", groups: ["cfr"], diff --git a/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs index 57fd104f19..36f7ca5005 100644 --- a/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs +++ b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs @@ -10,7 +10,6 @@ ChromeUtils.defineESModuleGetters(lazy, { clearTimeout: "resource://gre/modules/Timer.sys.mjs", requestIdleCallback: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", - ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs", }); let notificationsByWindow = new WeakMap(); @@ -19,9 +18,6 @@ export class _ToolbarBadgeHub { constructor() { this.id = "toolbar-badge-hub"; this.state = {}; - this.prefs = { - WHATSNEW_TOOLBAR_PANEL: "browser.messaging-system.whatsNewPanel.enabled", - }; this.removeAllNotifications = this.removeAllNotifications.bind(this); this.removeToolbarNotification = this.removeToolbarNotification.bind(this); this.addToolbarNotification = this.addToolbarNotification.bind(this); @@ -62,34 +58,12 @@ export class _ToolbarBadgeHub { triggerId: "toolbarBadgeUpdate", template: "toolbar_badge", }); - // Listen for pref changes that could trigger new badges - Services.prefs.addObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this); - } - - observe(aSubject, aTopic, aPrefName) { - switch (aPrefName) { - case this.prefs.WHATSNEW_TOOLBAR_PANEL: - this.messageRequest({ - triggerId: "toolbarBadgeUpdate", - template: "toolbar_badge", - }); - break; - } } maybeInsertFTL(win) { win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); } - executeAction({ id }) { - switch (id) { - case "show-whatsnew-button": - lazy.ToolbarPanelHub.enableToolbarButton(); - lazy.ToolbarPanelHub.enableAppmenuButton(); - break; - } - } - _clearBadgeTimeout() { if (this.state.showBadgeTimeoutId) { lazy.clearTimeout(this.state.showBadgeTimeoutId); @@ -153,9 +127,6 @@ export class _ToolbarBadgeHub { addToolbarNotification(win, message) { const document = win.browser.ownerDocument; - if (message.content.action) { - this.executeAction({ ...message.content.action, message_id: message.id }); - } let toolbarbutton = document.getElementById(message.content.target); if (toolbarbutton) { const badge = toolbarbutton.querySelector(".toolbarbutton-badge"); @@ -211,12 +182,6 @@ export class _ToolbarBadgeHub { } registerBadgeToAllWindows(message) { - if (message.template === "update_action") { - this.executeAction({ ...message.content.action, message_id: message.id }); - // No badge to set only an action to execute - return; - } - lazy.EveryWindow.registerCallback( this.id, win => { @@ -297,7 +262,6 @@ export class _ToolbarBadgeHub { this.state = {}; this._initialized = false; notificationsByWindow = new WeakMap(); - Services.prefs.removeObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this); } } diff --git a/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs deleted file mode 100644 index 519bca8a89..0000000000 --- a/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs +++ /dev/null @@ -1,544 +0,0 @@ -/* 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/. */ - -const lazy = {}; - -// We use importESModule here instead of static import so that -// the Karma test environment won't choke on this module. This -// is because the Karma test environment already stubs out -// XPCOMUtils. That environment overrides importESModule to be a no-op -// (which can't be done for a static import statement). - -// eslint-disable-next-line mozilla/use-static-import -const { XPCOMUtils } = ChromeUtils.importESModule( - "resource://gre/modules/XPCOMUtils.sys.mjs" -); - -ChromeUtils.defineESModuleGetters(lazy, { - EveryWindow: "resource:///modules/EveryWindow.sys.mjs", - PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", - PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", - SpecialMessageActions: - "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", - RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", -}); - -XPCOMUtils.defineLazyServiceGetter( - lazy, - "TrackingDBService", - "@mozilla.org/tracking-db-service;1", - "nsITrackingDBService" -); - -const idToTextMap = new Map([ - [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"], - [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"], - [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"], - [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"], - [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"], -]); - -const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled"; -const PROTECTIONS_PANEL_INFOMSG_PREF = - "browser.protections_panel.infoMessage.seen"; - -const TOOLBAR_BUTTON_ID = "whats-new-menu-button"; -const APPMENU_BUTTON_ID = "appMenu-whatsnew-button"; - -const BUTTON_STRING_ID = "cfr-whatsnew-button"; -const WHATS_NEW_PANEL_SELECTOR = "PanelUI-whatsNew-message-container"; - -export class _ToolbarPanelHub { - constructor() { - this.triggerId = "whatsNewPanelOpened"; - this._showAppmenuButton = this._showAppmenuButton.bind(this); - this._hideAppmenuButton = this._hideAppmenuButton.bind(this); - this._showToolbarButton = this._showToolbarButton.bind(this); - this._hideToolbarButton = this._hideToolbarButton.bind(this); - - this.state = {}; - this._initialized = false; - } - - async init(waitForInitialized, { getMessages, sendTelemetry }) { - if (this._initialized) { - return; - } - - this._initialized = true; - this._getMessages = getMessages; - this._sendTelemetry = sendTelemetry; - // Wait for ASRouter messages to become available in order to know - // if we can show the What's New panel - await waitForInitialized; - // Enable the application menu button so that the user can access - // the panel outside of the toolbar button - await this.enableAppmenuButton(); - - this.state = { - protectionPanelMessageSeen: Services.prefs.getBoolPref( - PROTECTIONS_PANEL_INFOMSG_PREF, - false - ), - }; - } - - uninit() { - this._initialized = false; - lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID); - lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID); - } - - get messages() { - return this._getMessages({ - template: "whatsnew_panel_message", - triggerId: "whatsNewPanelOpened", - returnAll: true, - }); - } - - toggleWhatsNewPref(event) { - // Checkbox onclick handler gets called before the checkbox state gets toggled, - // so we have to call it with the opposite value. - let newValue = !event.target.checked; - Services.prefs.setBoolPref(WHATSNEW_ENABLED_PREF, newValue); - - this.sendUserEventTelemetry( - event.target.ownerGlobal, - "WNP_PREF_TOGGLE", - // Message id is not applicable in this case, the notification state - // is not related to a particular message - { id: "n/a" }, - { value: { prefValue: newValue } } - ); - } - - maybeInsertFTL(win) { - win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); - win.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); - win.MozXULElement.insertFTLIfNeeded("toolkit/branding/accounts.ftl"); - } - - maybeLoadCustomElement(win) { - if (!win.customElements.get("remote-text")) { - Services.scriptloader.loadSubScript( - "resource://activity-stream/data/custom-elements/paragraph.js", - win - ); - } - } - - // Turns on the Appmenu (hamburger menu) button for all open windows and future windows. - async enableAppmenuButton() { - if ((await this.messages).length) { - lazy.EveryWindow.registerCallback( - APPMENU_BUTTON_ID, - this._showAppmenuButton, - this._hideAppmenuButton - ); - } - } - - // Removes the button from the Appmenu. - // Only used in tests. - disableAppmenuButton() { - lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID); - } - - // Turns on the Toolbar button for all open windows and future windows. - async enableToolbarButton() { - if ((await this.messages).length) { - lazy.EveryWindow.registerCallback( - TOOLBAR_BUTTON_ID, - this._showToolbarButton, - this._hideToolbarButton - ); - } - } - - // When the panel is hidden we want to run some cleanup - _onPanelHidden(win) { - const panelContainer = win.document.getElementById( - "customizationui-widget-panel" - ); - // When the panel is hidden we want to remove any toolbar buttons that - // might have been added as an entry point to the panel - const removeToolbarButton = () => { - lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID); - }; - if (!panelContainer) { - return; - } - panelContainer.addEventListener("popuphidden", removeToolbarButton, { - once: true, - }); - } - - // Newer messages first and use `order` field to decide between messages - // with the same timestamp - _sortWhatsNewMessages(m1, m2) { - // Sort by published_date in descending order. - if (m1.content.published_date === m2.content.published_date) { - // Ascending order - return m1.order - m2.order; - } - if (m1.content.published_date > m2.content.published_date) { - return -1; - } - return 1; - } - - // Render what's new messages into the panel. - async renderMessages(win, doc, containerId, options = {}) { - // Set the checked status of the footer checkbox - let value = Services.prefs.getBoolPref(WHATSNEW_ENABLED_PREF); - let checkbox = win.document.getElementById("panelMenu-toggleWhatsNew"); - - checkbox.checked = value; - - this.maybeLoadCustomElement(win); - const messages = - (options.force && options.messages) || - (await this.messages).sort(this._sortWhatsNewMessages); - const container = lazy.PanelMultiView.getViewNode(doc, containerId); - - if (messages) { - // Targeting attribute state might have changed making new messages - // available and old messages invalid, we need to refresh - this.removeMessages(win, containerId); - let previousDate = 0; - // Get and store any variable part of the message content - this.state.contentArguments = await this._contentArguments(); - for (let message of messages) { - container.appendChild( - this._createMessageElements(win, doc, message, previousDate) - ); - previousDate = message.content.published_date; - } - } - - this._onPanelHidden(win); - - // Panel impressions are not associated with one particular message - // but with a set of messages. We concatenate message ids and send them - // back for every impression. - const eventId = { - id: messages - .map(({ id }) => id) - .sort() - .join(","), - }; - // Check `mainview` attribute to determine if the panel is shown as a - // subview (inside the application menu) or as a toolbar dropdown. - // https://searchfox.org/mozilla-central/rev/07f7390618692fa4f2a674a96b9b677df3a13450/browser/components/customizableui/PanelMultiView.jsm#1268 - const mainview = win.PanelUI.whatsNewPanel.hasAttribute("mainview"); - this.sendUserEventTelemetry(win, "IMPRESSION", eventId, { - value: { view: mainview ? "toolbar_dropdown" : "application_menu" }, - }); - } - - removeMessages(win, containerId) { - const doc = win.document; - const messageNodes = lazy.PanelMultiView.getViewNode( - doc, - containerId - ).querySelectorAll(".whatsNew-message"); - for (const messageNode of messageNodes) { - messageNode.remove(); - } - } - - /** - * Dispatch the action defined in the message and user telemetry event. - */ - _dispatchUserAction(win, message) { - let url; - try { - // Set platform specific path variables for SUMO articles - url = Services.urlFormatter.formatURL(message.content.cta_url); - } catch (e) { - console.error(e); - url = message.content.cta_url; - } - lazy.SpecialMessageActions.handleAction( - { - type: message.content.cta_type, - data: { - args: url, - where: message.content.cta_where || "tabshifted", - }, - }, - win.browser - ); - - this.sendUserEventTelemetry(win, "CLICK", message); - } - - /** - * Attach event listener to dispatch message defined action. - */ - _attachCommandListener(win, element, message) { - // Add event listener for `mouseup` not to overlap with the - // `mousedown` & `click` events dispatched from PanelMultiView.sys.mjs - // https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837 - element.addEventListener("mouseup", () => { - this._dispatchUserAction(win, message); - }); - element.addEventListener("keyup", e => { - if (e.key === "Enter" || e.key === " ") { - this._dispatchUserAction(win, message); - } - }); - } - - _createMessageElements(win, doc, message, previousDate) { - const { content } = message; - const messageEl = lazy.RemoteL10n.createElement(doc, "div"); - messageEl.classList.add("whatsNew-message"); - - // Only render date if it is different from the one rendered before. - if (content.published_date !== previousDate) { - messageEl.appendChild( - lazy.RemoteL10n.createElement(doc, "p", { - classList: "whatsNew-message-date", - content: new Date(content.published_date).toLocaleDateString( - "default", - { - month: "long", - day: "numeric", - year: "numeric", - } - ), - }) - ); - } - - const wrapperEl = lazy.RemoteL10n.createElement(doc, "div"); - wrapperEl.doCommand = () => this._dispatchUserAction(win, message); - wrapperEl.classList.add("whatsNew-message-body"); - messageEl.appendChild(wrapperEl); - - if (content.icon_url) { - wrapperEl.classList.add("has-icon"); - const iconEl = lazy.RemoteL10n.createElement(doc, "img"); - iconEl.src = content.icon_url; - iconEl.classList.add("whatsNew-message-icon"); - if (content.icon_alt && content.icon_alt.string_id) { - doc.l10n.setAttributes(iconEl, content.icon_alt.string_id); - } else { - iconEl.setAttribute("alt", content.icon_alt); - } - wrapperEl.appendChild(iconEl); - } - - wrapperEl.appendChild(this._createMessageContent(win, doc, content)); - - if (content.link_text) { - const anchorEl = lazy.RemoteL10n.createElement(doc, "a", { - classList: "text-link", - content: content.link_text, - }); - anchorEl.doCommand = () => this._dispatchUserAction(win, message); - wrapperEl.appendChild(anchorEl); - } - - // Attach event listener on entire message container - this._attachCommandListener(win, messageEl, message); - - return messageEl; - } - - /** - * Return message title (optional subtitle) and body - */ - _createMessageContent(win, doc, content) { - const wrapperEl = new win.DocumentFragment(); - - wrapperEl.appendChild( - lazy.RemoteL10n.createElement(doc, "h2", { - classList: "whatsNew-message-title", - content: content.title, - attributes: this.state.contentArguments, - }) - ); - - wrapperEl.appendChild( - lazy.RemoteL10n.createElement(doc, "p", { - content: content.body, - classList: "whatsNew-message-content", - attributes: this.state.contentArguments, - }) - ); - - return wrapperEl; - } - - _createHeroElement(win, doc, message) { - this.maybeLoadCustomElement(win); - - const messageEl = lazy.RemoteL10n.createElement(doc, "div"); - messageEl.setAttribute("id", "protections-popup-message"); - messageEl.classList.add("whatsNew-hero-message"); - const wrapperEl = lazy.RemoteL10n.createElement(doc, "div"); - wrapperEl.classList.add("whatsNew-message-body"); - messageEl.appendChild(wrapperEl); - - wrapperEl.appendChild( - lazy.RemoteL10n.createElement(doc, "h2", { - classList: "whatsNew-message-title", - content: message.content.title, - }) - ); - wrapperEl.appendChild( - lazy.RemoteL10n.createElement(doc, "p", { - classList: "protections-popup-content", - content: message.content.body, - }) - ); - - if (message.content.link_text) { - let linkEl = lazy.RemoteL10n.createElement(doc, "a", { - classList: "text-link", - content: message.content.link_text, - }); - linkEl.disabled = true; - wrapperEl.appendChild(linkEl); - this._attachCommandListener(win, linkEl, message); - } else { - this._attachCommandListener(win, wrapperEl, message); - } - - return messageEl; - } - - async _contentArguments() { - const { defaultEngine } = Services.search; - // Between now and 6 weeks ago - const dateTo = new Date(); - const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000); - const eventsByDate = await lazy.TrackingDBService.getEventsByDateRange( - dateFrom, - dateTo - ); - // Make sure we set all types of possible values to 0 because they might - // be referenced by fluent strings - let totalEvents = { blockedCount: 0 }; - for (let blockedType of idToTextMap.values()) { - totalEvents[blockedType] = 0; - } - // Count all events in the past 6 weeks. Returns an object with: - // `blockedCount` total number of blocked resources - // {tracker|cookie|social...} breakdown by event type as defined by `idToTextMap` - totalEvents = eventsByDate.reduce((acc, day) => { - const type = day.getResultByName("type"); - const count = day.getResultByName("count"); - acc[idToTextMap.get(type)] = (acc[idToTextMap.get(type)] || 0) + count; - acc.blockedCount += count; - return acc; - }, totalEvents); - return { - // Keys need to match variable names used in asrouter.ftl - // `earliestDate` will be either 6 weeks ago or when tracking recording - // started. Whichever is more recent. - earliestDate: Math.max( - new Date(await lazy.TrackingDBService.getEarliestRecordedDate()), - dateFrom - ), - ...totalEvents, - // Passing in `undefined` as string for the Fluent variable name - // in order to match and select the message that does not require - // the variable. - searchEngineName: defaultEngine ? defaultEngine.name : "undefined", - }; - } - - async _showAppmenuButton(win) { - this.maybeInsertFTL(win); - await this._showElement( - win.browser.ownerDocument, - APPMENU_BUTTON_ID, - BUTTON_STRING_ID - ); - } - - _hideAppmenuButton(win, windowClosed) { - // No need to do something if the window is going away - if (!windowClosed) { - this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID); - } - } - - _showToolbarButton(win) { - const document = win.browser.ownerDocument; - this.maybeInsertFTL(win); - return this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID); - } - - _hideToolbarButton(win) { - this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID); - } - - _showElement(document, id, string_id) { - const el = lazy.PanelMultiView.getViewNode(document, id); - document.l10n.setAttributes(el, string_id); - el.hidden = false; - } - - _hideElement(document, id) { - const el = lazy.PanelMultiView.getViewNode(document, id); - if (el) { - el.hidden = true; - } - } - - _sendPing(ping) { - this._sendTelemetry({ - type: "TOOLBAR_PANEL_TELEMETRY", - data: { action: "whats-new-panel_user_event", ...ping }, - }); - } - - sendUserEventTelemetry(win, event, message, options = {}) { - // Only send pings for non private browsing windows - if ( - win && - !lazy.PrivateBrowsingUtils.isBrowserPrivate( - win.ownerGlobal.gBrowser.selectedBrowser - ) - ) { - this._sendPing({ - message_id: message.id, - event, - event_context: options.value, - }); - } - } - - /** - * @param {object} [browser] MessageChannel target argument as a response to a - * user action. No message is shown if undefined. - * @param {object[]} messages Messages selected from devtools page - */ - forceShowMessage(browser, messages) { - if (!browser) { - return; - } - const win = browser.ownerGlobal; - const doc = browser.ownerDocument; - this.removeMessages(win, WHATS_NEW_PANEL_SELECTOR); - this.renderMessages(win, doc, WHATS_NEW_PANEL_SELECTOR, { - force: true, - messages: Array.isArray(messages) ? messages : [messages], - }); - win.PanelUI.panel.addEventListener("popuphidden", event => - this.removeMessages(event.target.ownerGlobal, WHATS_NEW_PANEL_SELECTOR) - ); - } -} - -/** - * ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate - * message requests and render messages. - */ -export const ToolbarPanelHub = new _ToolbarPanelHub(); diff --git a/browser/components/asrouter/moz.build b/browser/components/asrouter/moz.build index 558ccbeb9b..cf45186619 100644 --- a/browser/components/asrouter/moz.build +++ b/browser/components/asrouter/moz.build @@ -15,7 +15,7 @@ FINAL_TARGET_FILES.actors += [ ] EXTRA_JS_MODULES.asrouter += [ - "modules/ActorConstants.sys.mjs", + "modules/ActorConstants.mjs", "modules/ASRouter.sys.mjs", "modules/ASRouterDefaultConfig.sys.mjs", "modules/ASRouterNewTabHook.sys.mjs", @@ -38,7 +38,6 @@ EXTRA_JS_MODULES.asrouter += [ "modules/Spotlight.sys.mjs", "modules/ToastNotification.sys.mjs", "modules/ToolbarBadgeHub.sys.mjs", - "modules/ToolbarPanelHub.sys.mjs", ] BROWSER_CHROME_MANIFESTS += [ @@ -57,7 +56,6 @@ TESTING_JS_MODULES += [ "content-src/templates/OnboardingMessage/Spotlight.schema.json", "content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json", "content-src/templates/OnboardingMessage/UpdateAction.schema.json", - "content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json", "content-src/templates/PBNewtab/NewtabPromoMessage.schema.json", "content-src/templates/ToastNotification/ToastNotification.schema.json", "tests/InflightAssetsMessageProvider.sys.mjs", diff --git a/browser/components/asrouter/package.json b/browser/components/asrouter/package.json index 17bc8f7364..26c09392c2 100644 --- a/browser/components/asrouter/package.json +++ b/browser/components/asrouter/package.json @@ -60,6 +60,7 @@ "testmc:lint": "npm run lint", "testmc:build": "npm run bundle:admin", "testmc:unit": "karma start karma.mc.config.js", + "testmc:import": "npm run import-rollouts", "tddmc": "karma start karma.mc.config.js --tdd", "debugcoverage": "open logs/coverage/lcov-report/index.html", "lint": "npm-run-all lint:*", diff --git a/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs index e92b210c12..adb14ecc38 100644 --- a/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs +++ b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs @@ -10,58 +10,10 @@ export const InflightAssetsMessageProvider = { getMessages() { return [ { - id: "MILESTONE_MESSAGE", - groups: ["cfr"], - content: { - anchor_id: "tracking-protection-icon-box", - bucket_id: "CFR_MILESTONE_MESSAGE", - buttons: { - primary: { - action: { - type: "OPEN_PROTECTION_REPORT", - }, - event: "PROTECTION", - label: { - string_id: "cfr-doorhanger-milestone-ok-button", - }, - }, - secondary: [ - { - label: { - string_id: "cfr-doorhanger-milestone-close-button", - }, - action: { - type: "CANCEL", - }, - event: "DISMISS", - }, - ], - }, - category: "cfrFeatures", - heading_text: { - string_id: "cfr-doorhanger-milestone-heading", - }, - layout: "short_message", - notification_text: "", - skip_address_bar_notifier: true, - text: "", - }, - frequency: { - lifetime: 7, - }, - targeting: - "pageLoad >= 4 && firefoxVersion < 87 && userPrefs.cfrFeatures", - template: "milestone_message", - trigger: { - id: "contentBlocking", - params: ["ContentBlockingMilestone"], - }, - }, - { id: "MILESTONE_MESSAGE_87", groups: ["cfr"], content: { - anchor_id: "tracking-protection-icon-box", + anchor_id: "tracking-protection-icon-container", bucket_id: "CFR_MILESTONE_MESSAGE", buttons: { primary: { @@ -98,7 +50,7 @@ export const InflightAssetsMessageProvider = { lifetime: 7, }, targeting: - "pageLoad >= 4 && firefoxVersion >= 87 && userPrefs.cfrFeatures", + "pageLoad >= 4 && firefoxVersion >= 115 && firefoxVersion < 121 && userPrefs.cfrFeatures", template: "milestone_message", trigger: { id: "contentBlocking", @@ -275,66 +227,6 @@ export const InflightAssetsMessageProvider = { ], }, }, - { - id: "WNP_MOMENTS_12", - groups: ["moments-pages"], - content: { - action: { - data: { - expire: 1640908800000, - url: "https://www.mozilla.org/firefox/welcome/12", - }, - id: "moments-wnp", - }, - bucket_id: "WNP_MOMENTS_12", - }, - targeting: - 'localeLanguageCode == "en" && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 1 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', - template: "update_action", - trigger: { - id: "momentsUpdate", - }, - }, - { - id: "WNP_MOMENTS_13", - groups: ["moments-pages"], - content: { - action: { - data: { - expire: 1640908800000, - url: "https://www.mozilla.org/firefox/welcome/13", - }, - id: "moments-wnp", - }, - bucket_id: "WNP_MOMENTS_13", - }, - targeting: - '(localeLanguageCode in ["en", "de", "fr", "nl", "it", "ms"] || locale == "es-ES") && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 0 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', - template: "update_action", - trigger: { - id: "momentsUpdate", - }, - }, - { - id: "WNP_MOMENTS_14", - groups: ["moments-pages"], - content: { - action: { - data: { - expire: 1668470400000, - url: "https://www.mozilla.org/firefox/welcome/14", - }, - id: "moments-wnp", - }, - bucket_id: "WNP_MOMENTS_14", - }, - targeting: - 'localeLanguageCode in ["en", "de", "fr"] && region in ["AT", "BE", "CA", "CH", "DE", "ES", "FI", "FR", "GB", "IE", "IT", "MY", "NL", "NZ", "SE", "SG", "US"] && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', - template: "update_action", - trigger: { - id: "momentsUpdate", - }, - }, ]; }, }; diff --git a/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs index 5bfbec9557..f2c94f7de6 100644 --- a/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs +++ b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs @@ -12,6 +12,427 @@ export const NimbusRolloutMessageProvider = { getMessages() { return [ { + // Nimbus slug: device-migration-q4-spotlights-remaining-population-esr:treatment (message 1 of 3) + // Recipe: https://experimenter.services.mozilla.com/nimbus/device-migration-q4-spotlights-remaining-population-esr/summary#treatment + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + groups: ["eco"], + content: { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + modal: "tab", + screens: [ + { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT_BACKUP", + content: { + logo: { + height: "152px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/c92a41e4-82cf-4ad5-8480-04a138bfb3cd.png", + }, + title: { + fontSize: "24px", + string_id: "device-migration-fxa-spotlight-heavy-user-header", + letterSpacing: 0, + }, + subtitle: { + fontSize: "15px", + string_id: "device-migration-fxa-spotlight-heavy-user-body", + lineHeight: "1.4", + marginBlock: "8px 20px", + letterSpacing: 0, + paddingInline: "20px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + string_id: + "device-migration-fxa-spotlight-heavy-user-primary-button", + paddingBlock: "4px", + paddingInline: "16px", + }, + action: { + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/switching-devices?utm_source=spotlight-default&utm_medium=firefox-desktop&utm_campaign=migration&utm_content=dont-forget-to-backup&entrypoint=device-migration-spotlight-experiment-v2", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && !usesFirefoxSync && !hasActiveEnterprisePolicies && userMonthlyActivity[userMonthlyActivity|length - 2][1]|date >= currentDate|date - (28 * 24 * 60 * 60 * 1000) && (((currentDate|date - profileAgeCreated|date) / 86400000 >= 168) || totalBookmarksCount >= 35)", + }, + { + // Nimbus slug: device-migration-q4-spotlights-remaining-population-esr:treatment (message 2 of 3) + // Recipe: https://experimenter.services.mozilla.com/nimbus/device-migration-q4-spotlights-remaining-population-esr/summary#treatment + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + groups: ["eco"], + content: { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + modal: "tab", + screens: [ + { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT_PEACE", + content: { + logo: { + height: "133px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/4a56d3ed-98c8-4a33-b853-b2cf7646efd8.png", + marginBlock: "22px -10px", + }, + title: { + fontSize: "24px", + string_id: + "device-migration-fxa-spotlight-older-device-header", + letterSpacing: 0, + }, + subtitle: { + fontSize: "15px", + string_id: "device-migration-fxa-spotlight-older-device-body", + lineHeight: "1.4", + marginBlock: "8px 20px", + letterSpacing: 0, + paddingInline: "40px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + string_id: + "device-migration-fxa-spotlight-older-device-primary-button", + marginBlock: "0 22px", + paddingBlock: "4px", + paddingInline: "16px", + }, + action: { + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/switching-devices?utm_source=spotlight-default&utm_medium=firefox-desktop&utm_campaign=migration&utm_content=peace-of-mind&entrypoint=device-migration-spotlight-experiment-v2", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && !usesFirefoxSync && !hasActiveEnterprisePolicies && userMonthlyActivity[userMonthlyActivity|length - 2][1]|date >= currentDate|date - (28 * 24 * 60 * 60 * 1000) && !(((currentDate|date - profileAgeCreated|date) / 86400000 >= 168) || totalBookmarksCount >= 35) && os.isWindows && os.windowsVersion >= 6.1 && os.windowsBuildNumber < 22000", + }, + { + // Nimbus slug: device-migration-q4-spotlights-remaining-population-esr:treatment (message 3 of 3) + // Recipe: https://experimenter.services.mozilla.com/nimbus/device-migration-q4-spotlights-remaining-population-esr/summary#treatment + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + groups: ["eco"], + content: { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + modal: "tab", + screens: [ + { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT_NEW_DEVICE", + content: { + logo: { + height: "149px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a43cd9cc-e8b2-477c-92f2-345557370de1.svg", + }, + title: { + fontSize: "24px", + string_id: + "device-migration-fxa-spotlight-getting-new-device-header-2", + letterSpacing: 0, + }, + subtitle: { + fontSize: "15px", + string_id: + "device-migration-fxa-spotlight-getting-new-device-body-2", + lineHeight: "1.4", + marginBlock: "8px 20px", + letterSpacing: 0, + paddingInline: "40px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + string_id: + "device-migration-fxa-spotlight-getting-new-device-primary-button", + paddingBlock: "4px", + paddingInline: "16px", + }, + action: { + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/switching-devices?utm_source=spotlight-default&utm_medium=firefox-desktop&utm_campaign=migration&utm_content=new-device-in-your-future&entrypoint=device-migration-spotlight-experiment-v2", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && !usesFirefoxSync && !hasActiveEnterprisePolicies && userMonthlyActivity[userMonthlyActivity|length - 2][1]|date >= currentDate|date - (28 * 24 * 60 * 60 * 1000) && !(((currentDate|date - profileAgeCreated|date) / 86400000 >= 168) || totalBookmarksCount >= 35) && !(os.isWindows && os.windowsVersion >= 6.1 && os.windowsBuildNumber < 22000)", + }, + { + // Nimbus slug: device-migration-q4-spotlights-remaining-population:treatment (message 1 of 3) + // Version range: 122+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/device-migration-q4-spotlights-remaining-population/summary#treatment + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + groups: ["eco"], + content: { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + modal: "tab", + screens: [ + { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT_BACKUP", + content: { + logo: { + height: "152px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/c92a41e4-82cf-4ad5-8480-04a138bfb3cd.png", + }, + title: { + fontSize: "24px", + string_id: "device-migration-fxa-spotlight-heavy-user-header", + letterSpacing: 0, + }, + subtitle: { + fontSize: "15px", + string_id: "device-migration-fxa-spotlight-heavy-user-body", + lineHeight: "1.4", + marginBlock: "8px 20px", + letterSpacing: 0, + paddingInline: "20px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + string_id: + "device-migration-fxa-spotlight-heavy-user-primary-button", + paddingBlock: "4px", + paddingInline: "16px", + }, + action: { + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/switching-devices?utm_source=spotlight-default&utm_medium=firefox-desktop&utm_campaign=migration&utm_content=dont-forget-to-backup&entrypoint=device-migration-spotlight-experiment-v2", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && !usesFirefoxSync && !hasActiveEnterprisePolicies && userMonthlyActivity[userMonthlyActivity|length - 2][1]|date >= currentDate|date - (28 * 24 * 60 * 60 * 1000) && (((currentDate|date - profileAgeCreated|date) / 86400000 >= 168) || totalBookmarksCount >= 35)", + }, + { + // Nimbus slug: device-migration-q4-spotlights-remaining-population:treatment (message 2 of 3) + // Version range: 122+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/device-migration-q4-spotlights-remaining-population/summary#treatment + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + groups: ["eco"], + content: { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + modal: "tab", + screens: [ + { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT_PEACE", + content: { + logo: { + height: "133px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/4a56d3ed-98c8-4a33-b853-b2cf7646efd8.png", + marginBlock: "22px -10px", + }, + title: { + fontSize: "24px", + string_id: + "device-migration-fxa-spotlight-older-device-header", + letterSpacing: 0, + }, + subtitle: { + fontSize: "15px", + string_id: "device-migration-fxa-spotlight-older-device-body", + lineHeight: "1.4", + marginBlock: "8px 20px", + letterSpacing: 0, + paddingInline: "40px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + string_id: + "device-migration-fxa-spotlight-older-device-primary-button", + marginBlock: "0 22px", + paddingBlock: "4px", + paddingInline: "16px", + }, + action: { + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/switching-devices?utm_source=spotlight-default&utm_medium=firefox-desktop&utm_campaign=migration&utm_content=peace-of-mind&entrypoint=device-migration-spotlight-experiment-v2", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && !usesFirefoxSync && !hasActiveEnterprisePolicies && userMonthlyActivity[userMonthlyActivity|length - 2][1]|date >= currentDate|date - (28 * 24 * 60 * 60 * 1000) && !(((currentDate|date - profileAgeCreated|date) / 86400000 >= 168) || totalBookmarksCount >= 35) && os.isWindows && os.windowsVersion >= 6.1 && os.windowsBuildNumber < 22000", + }, + { + // Nimbus slug: device-migration-q4-spotlights-remaining-population:treatment (message 3 of 3) + // Version range: 122+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/device-migration-q4-spotlights-remaining-population/summary#treatment + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + groups: ["eco"], + content: { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT", + modal: "tab", + screens: [ + { + id: "Q4_DEVICE_MIGRATION_BACKUP_SPOTLIGHT_NEW_DEVICE", + content: { + logo: { + height: "149px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a43cd9cc-e8b2-477c-92f2-345557370de1.svg", + }, + title: { + fontSize: "24px", + string_id: + "device-migration-fxa-spotlight-getting-new-device-header-2", + letterSpacing: 0, + }, + subtitle: { + fontSize: "15px", + string_id: + "device-migration-fxa-spotlight-getting-new-device-body-2", + lineHeight: "1.4", + marginBlock: "8px 20px", + letterSpacing: 0, + paddingInline: "40px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + string_id: + "device-migration-fxa-spotlight-getting-new-device-primary-button", + paddingBlock: "4px", + paddingInline: "16px", + }, + action: { + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/switching-devices?utm_source=spotlight-default&utm_medium=firefox-desktop&utm_campaign=migration&utm_content=new-device-in-your-future&entrypoint=device-migration-spotlight-experiment-v2", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && !usesFirefoxSync && !hasActiveEnterprisePolicies && userMonthlyActivity[userMonthlyActivity|length - 2][1]|date >= currentDate|date - (28 * 24 * 60 * 60 * 1000) && !(((currentDate|date - profileAgeCreated|date) / 86400000 >= 168) || totalBookmarksCount >= 35) && !(os.isWindows && os.windowsVersion >= 6.1 && os.windowsBuildNumber < 22000)", + }, + { // Nimbus slug: fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout:treatment-a // Version range: 116+ // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout/summary#treatment-a diff --git a/browser/components/asrouter/tests/browser/browser.toml b/browser/components/asrouter/tests/browser/browser.toml index 7bed40373d..60ce42dfd8 100644 --- a/browser/components/asrouter/tests/browser/browser.toml +++ b/browser/components/asrouter/tests/browser/browser.toml @@ -21,6 +21,11 @@ skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1643036 ["browser_asrouter_infobar.js"] +["browser_asrouter_keyboard_cfr.js"] +https_first_disabled = true + +["browser_asrouter_milestone_message_cfr.js"] + ["browser_asrouter_momentspagehub.js"] tags = "remote-settings" diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js index 19fcb63131..d22605d589 100644 --- a/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js +++ b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js @@ -20,7 +20,7 @@ const { RemoteSettings } = ChromeUtils.importESModule( ); // This pref is used to override the Remote Settings server URL in tests. -// See SERVER_URL in services/settings/Utils.jsm for more details. +// See SERVER_URL in services/settings/Utils.sys.mjs for more details. const RS_SERVER_PREF = "services.settings.server"; const FLUENT_CONTENT = "asrouter-test-string = Test Test Test\n"; diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js index e29771c24f..1979c81a79 100644 --- a/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js +++ b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js @@ -115,14 +115,6 @@ function checkCFRAddonsElements(notification) { ); } -function checkCFRTrackingProtectionMilestone(notification) { - Assert.ok(notification.hidden === false, "Panel should be visible"); - Assert.ok( - notification.getAttribute("data-notification-category") === "short_message", - "Panel have correct data attribute" - ); -} - function clearNotifications() { for (let notification of PopupNotifications._currentNotifications) { notification.remove(); @@ -498,59 +490,6 @@ add_task(async function test_cfr_addon_install() { Services.fog.testResetFOG(); }); -add_task( - async function test_cfr_tracking_protection_milestone_notification_remove() { - await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.contentblocking.cfr-milestone.milestone-achieved", 1000], - [ - "browser.newtabpage.activity-stream.asrouter.providers.cfr", - `{"id":"cfr","enabled":true,"type":"local","localProvider":"CFRMessageProvider","updateCycleInMs":3600000}`, - ], - ], - }); - - // addRecommendation checks that scheme starts with http and host matches - let browser = gBrowser.selectedBrowser; - BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); - await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); - - const showPanel = BrowserTestUtils.waitForEvent( - PopupNotifications.panel, - "popupshown" - ); - - Services.obs.notifyObservers( - { - wrappedJSObject: { - event: "ContentBlockingMilestone", - }, - }, - "SiteProtection:ContentBlockingMilestone" - ); - - await showPanel; - - const notification = document.getElementById( - "contextual-feature-recommendation-notification" - ); - - checkCFRTrackingProtectionMilestone(notification); - - Assert.ok(notification.secondaryButton); - let hidePanel = BrowserTestUtils.waitForEvent( - PopupNotifications.panel, - "popuphidden" - ); - - notification.secondaryButton.click(); - await hidePanel; - await SpecialPowers.popPrefEnv(); - clearNotifications(); - Services.fog.testResetFOG(); - } -); - add_task(async function test_cfr_addon_and_features_show() { // addRecommendation checks that scheme starts with http and host matches let browser = gBrowser.selectedBrowser; @@ -747,62 +686,6 @@ add_task(async function test_providerNames() { } }); -add_task(async function test_cfr_notification_keyboard() { - // addRecommendation checks that scheme starts with http and host matches - const browser = gBrowser.selectedBrowser; - BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); - await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); - - const response = await trigger_cfr_panel(browser, "example.com"); - Assert.ok( - response, - "Should return true if addRecommendation checks were successful" - ); - - // Open the panel with the keyboard. - // Toolbar buttons aren't always focusable; toolbar keyboard navigation - // makes them focusable on demand. Therefore, we must force focus. - const button = document.getElementById("contextual-feature-recommendation"); - button.setAttribute("tabindex", "-1"); - button.focus(); - button.removeAttribute("tabindex"); - - let focused = BrowserTestUtils.waitForEvent( - PopupNotifications.panel, - "focus", - true - ); - EventUtils.synthesizeKey(" "); - await focused; - Assert.ok(true, "Focus inside panel after button pressed"); - - let hidden = BrowserTestUtils.waitForEvent( - PopupNotifications.panel, - "popuphidden" - ); - EventUtils.synthesizeKey("KEY_Escape"); - await hidden; - Assert.ok(true, "Panel hidden after Escape pressed"); - - const showPanel = BrowserTestUtils.waitForEvent( - PopupNotifications.panel, - "popupshown" - ); - // Need to dismiss the notification to clear the RecommendationMap - document.getElementById("contextual-feature-recommendation").click(); - await showPanel; - - const hidePanel = BrowserTestUtils.waitForEvent( - PopupNotifications.panel, - "popuphidden" - ); - document - .getElementById("contextual-feature-recommendation-notification") - .button.click(); - await hidePanel; - Services.fog.testResetFOG(); -}); - add_task(function test_updateCycleForProviders() { Services.prefs .getChildList("browser.newtabpage.activity-stream.asrouter.providers.") diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_keyboard_cfr.js b/browser/components/asrouter/tests/browser/browser_asrouter_keyboard_cfr.js new file mode 100644 index 0000000000..c3dfc0b0bb --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_keyboard_cfr.js @@ -0,0 +1,162 @@ +/* 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/. */ + +/* eslint-disable @microsoft/sdl/no-insecure-url */ +function clearNotifications() { + for (let notification of PopupNotifications._currentNotifications) { + notification.remove(); + } + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); +} + +add_setup(async function () { + // Store it in order to restore to the original value + const { _fetchLatestAddonVersion } = CFRPageActions; + // Prevent fetching the real addon url and making a network request + CFRPageActions._fetchLatestAddonVersion = () => "http://example.com"; + Services.fog.testResetFOG(); + + registerCleanupFunction(() => { + CFRPageActions._fetchLatestAddonVersion = _fetchLatestAddonVersion; + clearNotifications(); + CFRPageActions.clearRecommendations(); + }); +}); + +add_task(async function test_cfr_notification_keyboard() { + // addRecommendation checks that scheme starts with http and host matches + const browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + clearNotifications(); + + let recommendation = { + template: "cfr_doorhanger", + groups: ["mochitest-group"], + content: { + layout: "addon_recommendation", + category: "cfrAddons", + anchor_id: "page-action-buttons", + icon_class: "cfr-doorhanger-medium-icon", + skip_address_bar_notifier: false, + heading_text: "Sample Mochitest", + icon: "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg", + icon_dark_theme: + "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg", + info_icon: { + label: { attributes: { tooltiptext: "Why am I seeing this" } }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "addon-id", + title: "Addon name", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + author: "Author name", + amo_url: "https://example.com", + rating: "4.5", + users: "1.1M", + }, + text: "Mochitest", + buttons: { + primary: { + label: { + value: "OK", + attributes: { accesskey: "O" }, + }, + action: { + type: "CANCEL", + data: {}, + }, + }, + secondary: [ + { + label: { + value: "Cancel", + attributes: { accesskey: "C" }, + }, + action: { + type: "CANCEL", + }, + }, + ], + }, + }, + }; + + recommendation.content.notification_text = new String("Mochitest"); // eslint-disable-line + recommendation.content.notification_text.attributes = { + tooltiptext: "Mochitest tooltip", + "a11y-announcement": "Mochitest announcement", + }; + + const response = await CFRPageActions.addRecommendation( + gBrowser.selectedBrowser, + "example.com", + recommendation, + // Use the real AS dispatch method to trigger real notifications + ASRouter.dispatchCFRAction + ); + + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + // Open the panel with the keyboard. + // Toolbar buttons aren't always focusable; toolbar keyboard navigation + // makes them focusable on demand. Therefore, we must force focus. + const button = document.getElementById("contextual-feature-recommendation"); + button.setAttribute("tabindex", "-1"); + + let buttonFocused = BrowserTestUtils.waitForEvent(button, "focus"); + button.focus(); + await buttonFocused; + + Assert.ok(true, "Focus page action button"); + + let focused = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "focus", + true + ); + + EventUtils.synthesizeKey(" "); + await focused; + Assert.ok(true, "Focus inside panel after button pressed"); + + button.removeAttribute("tabindex"); + + let hidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + EventUtils.synthesizeKey("KEY_Escape"); + await hidden; + Assert.ok(true, "Panel hidden after Escape pressed"); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Need to dismiss the notification to clear the RecommendationMap + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + const hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; + Services.fog.testResetFOG(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_milestone_message_cfr.js b/browser/components/asrouter/tests/browser/browser_asrouter_milestone_message_cfr.js new file mode 100644 index 0000000000..6585963d6f --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_milestone_message_cfr.js @@ -0,0 +1,78 @@ +/* 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/. */ + +/* eslint-disable @microsoft/sdl/no-insecure-url */ +function checkCFRTrackingProtectionMilestone(notification) { + Assert.ok(notification.hidden === false, "Panel should be visible"); + Assert.ok( + notification.getAttribute("data-notification-category") === "short_message", + "Panel have correct data attribute" + ); +} + +function clearNotifications() { + for (let notification of PopupNotifications._currentNotifications) { + notification.remove(); + } + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); +} + +add_task( + async function test_cfr_tracking_protection_milestone_notification_remove() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.cfr-milestone.milestone-achieved", 1000], + [ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + `{"id":"cfr","enabled":true,"type":"local","localProvider":"CFRMessageProvider","updateCycleInMs":3600000}`, + ], + ], + }); + + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + event: "ContentBlockingMilestone", + }, + }, + "SiteProtection:ContentBlockingMilestone" + ); + + await showPanel; + + const notification = document.getElementById( + "contextual-feature-recommendation-notification" + ); + + checkCFRTrackingProtectionMilestone(notification); + + Assert.ok(notification.secondaryButton); + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + + notification.secondaryButton.click(); + await hidePanel; + await SpecialPowers.popPrefEnv(); + clearNotifications(); + Services.fog.testResetFOG(); + } +); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js index f752d01116..aea702bb61 100644 --- a/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js +++ b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js @@ -14,11 +14,11 @@ const { ASRouter } = ChromeUtils.importESModule( const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; add_task(async function test_with_rs_messages() { - // Force the WNPanel provider cache to 0 by modifying updateCycleInMs + // Force the cfr provider cache to 0 by modifying updateCycleInMs await SpecialPowers.pushPrefEnv({ set: [ [ - "browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel", + "browser.newtabpage.activity-stream.asrouter.providers.cfr", `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`, ], ], diff --git a/browser/components/asrouter/tests/unit/ASRouter.test.js b/browser/components/asrouter/tests/unit/ASRouter.test.js index 7df1449a14..1f5899fce1 100644 --- a/browser/components/asrouter/tests/unit/ASRouter.test.js +++ b/browser/components/asrouter/tests/unit/ASRouter.test.js @@ -48,7 +48,6 @@ describe("ASRouter", () => { let fakeAttributionCode; let fakeTargetingContext; let FakeToolbarBadgeHub; - let FakeToolbarPanelHub; let FakeMomentsPageHub; let ASRouterTargeting; let screenImpressions; @@ -151,7 +150,6 @@ describe("ASRouter", () => { cfr: "", "message-groups": "", "messaging-experiments": "", - "whats-new-panel": "", }, totalBookmarksCount: {}, firefoxVersion: 80, @@ -159,7 +157,6 @@ describe("ASRouter", () => { needsUpdate: {}, hasPinnedTabs: false, hasAccessedFxAPanel: false, - isWhatsNewPanelEnabled: true, userPrefs: { cfrFeatures: true, cfrAddons: true, @@ -203,12 +200,6 @@ describe("ASRouter", () => { writeAttributionFile: () => Promise.resolve(), getCachedAttributionData: sinon.stub(), }; - FakeToolbarPanelHub = { - init: sandbox.stub(), - uninit: sandbox.stub(), - forceShowMessage: sandbox.stub(), - enableToolbarButton: sandbox.stub(), - }; FakeToolbarBadgeHub = { init: sandbox.stub(), uninit: sandbox.stub(), @@ -252,7 +243,6 @@ describe("ASRouter", () => { PanelTestProvider, MacAttribution: { applicationPath: "" }, ToolbarBadgeHub: FakeToolbarBadgeHub, - ToolbarPanelHub: FakeToolbarPanelHub, MomentsPageHub: FakeMomentsPageHub, KintoHttpClient: class { bucket() { @@ -354,7 +344,6 @@ describe("ASRouter", () => { // ASRouter init called in `beforeEach` block above assert.calledOnce(FakeToolbarBadgeHub.init); - assert.calledOnce(FakeToolbarPanelHub.init); assert.calledOnce(FakeMomentsPageHub.init); assert.calledWithExactly( @@ -370,15 +359,6 @@ describe("ASRouter", () => { ); assert.calledWithExactly( - FakeToolbarPanelHub.init, - Router.waitForInitialized, - { - getMessages: Router.handleMessageRequest, - sendTelemetry: Router.sendTelemetry, - } - ); - - assert.calledWithExactly( FakeMomentsPageHub.init, Router.waitForInitialized, { @@ -678,25 +658,10 @@ describe("ASRouter", () => { sandbox.stub(CFRPageActions, "addRecommendation"); browser = {}; }); - it("should route whatsnew_panel_message message to the right hub", () => { - Router.routeCFRMessage( - { template: "whatsnew_panel_message" }, - browser, - "", - true - ); - - assert.calledOnce(FakeToolbarPanelHub.forceShowMessage); - assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); - assert.notCalled(CFRPageActions.addRecommendation); - assert.notCalled(CFRPageActions.forceRecommendation); - assert.notCalled(FakeMomentsPageHub.executeAction); - }); it("should route moments messages to the right hub", () => { Router.routeCFRMessage({ template: "update_action" }, browser, "", true); assert.calledOnce(FakeMomentsPageHub.executeAction); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); assert.notCalled(CFRPageActions.addRecommendation); assert.notCalled(CFRPageActions.forceRecommendation); @@ -705,7 +670,6 @@ describe("ASRouter", () => { Router.routeCFRMessage({ template: "toolbar_badge" }, browser); assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(CFRPageActions.addRecommendation); assert.notCalled(CFRPageActions.forceRecommendation); assert.notCalled(FakeMomentsPageHub.executeAction); @@ -721,7 +685,6 @@ describe("ASRouter", () => { assert.calledOnce(CFRPageActions.addRecommendation); assert.notCalled(CFRPageActions.forceRecommendation); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(FakeMomentsPageHub.executeAction); }); it("should route cfr_doorhanger message to the right hub force = false", () => { @@ -733,7 +696,6 @@ describe("ASRouter", () => { ); assert.calledOnce(CFRPageActions.addRecommendation); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); assert.notCalled(CFRPageActions.forceRecommendation); assert.notCalled(FakeMomentsPageHub.executeAction); @@ -742,7 +704,6 @@ describe("ASRouter", () => { Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true); assert.calledOnce(CFRPageActions.forceRecommendation); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(CFRPageActions.addRecommendation); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); assert.notCalled(FakeMomentsPageHub.executeAction); @@ -759,7 +720,6 @@ describe("ASRouter", () => { const { args } = CFRPageActions.addRecommendation.firstCall; // Host should be null assert.isNull(args[1]); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); assert.notCalled(CFRPageActions.forceRecommendation); assert.notCalled(FakeMomentsPageHub.executeAction); @@ -773,7 +733,6 @@ describe("ASRouter", () => { ); assert.calledOnce(CFRPageActions.forceRecommendation); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(CFRPageActions.addRecommendation); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); assert.notCalled(FakeMomentsPageHub.executeAction); @@ -786,7 +745,6 @@ describe("ASRouter", () => { true ); - assert.notCalled(FakeToolbarPanelHub.forceShowMessage); assert.notCalled(CFRPageActions.forceRecommendation); assert.notCalled(CFRPageActions.addRecommendation); assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); @@ -961,7 +919,6 @@ describe("ASRouter", () => { type: "local", enabled: true, messages: [ - "whatsnew_panel_message", "cfr_doorhanger", "toolbar_badge", "update_action", @@ -1272,43 +1229,6 @@ describe("ASRouter", () => { Router.state.messageImpressions ); }); - it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => { - const message1 = { - provider: "whats_new", - id: "1", - template: "whatsnew_panel_message", - trigger: { id: "whatsNewPanelOpened" }, - groups: ["whats_new"], - }; - const message2 = { - provider: "whats_new", - id: "2", - template: "whatsnew_panel_message", - trigger: { id: "whatsNewPanelOpened" }, - groups: ["whats_new"], - }; - const message3 = { - provider: "whats_new", - id: "3", - template: "badge", - groups: ["whats_new"], - }; - ASRouterTargeting.findMatchingMessage.callsFake(() => [ - message2, - message1, - ]); - await Router.setState({ - messages: [message3, message2, message1], - providers: [{ id: "whats_new" }], - }); - const result = await Router.handleMessageRequest({ - template: "whatsnew_panel_message", - triggerId: "whatsNewPanelOpened", - returnAll: true, - }); - - assert.deepEqual(result, [message2, message1]); - }); it("should forward trigger param info", async () => { const trigger = { triggerId: "foo", @@ -1854,33 +1774,6 @@ describe("ASRouter", () => { }); }); - describe("#forceWNPanel", () => { - let browser = { - ownerGlobal: { - document: new Document(), - PanelUI: { - showSubView: sinon.stub(), - panel: { - setAttribute: sinon.stub(), - }, - }, - }, - }; - let fakePanel = { - setAttribute: sinon.stub(), - }; - sinon - .stub(browser.ownerGlobal.document, "getElementById") - .returns(fakePanel); - - it("should call enableToolbarButton", async () => { - await Router.forceWNPanel(browser); - assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton); - assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView); - assert.calledWith(fakePanel.setAttribute, "noautohide", true); - }); - }); - describe("_triggerHandler", () => { it("should call #sendTriggerMessage with the correct trigger", () => { const getter = sandbox.stub(); diff --git a/browser/components/asrouter/tests/unit/ASRouterChild.test.js b/browser/components/asrouter/tests/unit/ASRouterChild.test.js index b73e56d510..c6533e073d 100644 --- a/browser/components/asrouter/tests/unit/ASRouterChild.test.js +++ b/browser/components/asrouter/tests/unit/ASRouterChild.test.js @@ -1,6 +1,6 @@ /*eslint max-nested-callbacks: ["error", 10]*/ import { ASRouterChild } from "actors/ASRouterChild.sys.mjs"; -import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.mjs"; describe("ASRouterChild", () => { let asRouterChild = null; @@ -24,7 +24,6 @@ describe("ASRouterChild", () => { msg.DISABLE_PROVIDER, msg.ENABLE_PROVIDER, msg.EXPIRE_QUERY_CACHE, - msg.FORCE_WHATSNEW_PANEL, msg.IMPRESSION, msg.RESET_PROVIDER_PREF, msg.SET_PROVIDER_USER_PREF, diff --git a/browser/components/asrouter/tests/unit/ASRouterParent.test.js b/browser/components/asrouter/tests/unit/ASRouterParent.test.js index 0358b1261c..e65d7db825 100644 --- a/browser/components/asrouter/tests/unit/ASRouterParent.test.js +++ b/browser/components/asrouter/tests/unit/ASRouterParent.test.js @@ -1,5 +1,5 @@ import { ASRouterParent } from "actors/ASRouterParent.sys.mjs"; -import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.mjs"; describe("ASRouterParent", () => { let asRouterParent = null; diff --git a/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js index 7bfec3e099..6a965c5689 100644 --- a/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js +++ b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js @@ -1,6 +1,6 @@ import { ASRouterParentProcessMessageHandler } from "modules/ASRouterParentProcessMessageHandler.sys.mjs"; import { _ASRouter } from "modules/ASRouter.sys.mjs"; -import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.mjs"; describe("ASRouterParentProcessMessageHandler", () => { let handler = null; @@ -14,8 +14,6 @@ describe("ASRouterParentProcessMessageHandler", () => { "addImpression", "evaluateExpression", "forceAttribution", - "forceWNPanel", - "closeWNPanel", "forcePBWindow", "resetGroupsState", "resetMessageState", @@ -122,7 +120,6 @@ describe("ASRouterParentProcessMessageHandler", () => { [ msg.AS_ROUTER_TELEMETRY_USER_EVENT, msg.TOOLBAR_BADGE_TELEMETRY, - msg.TOOLBAR_PANEL_TELEMETRY, msg.MOMENTS_PAGE_TELEMETRY, msg.DOORHANGER_TELEMETRY, ].forEach(type => { @@ -309,28 +306,6 @@ describe("ASRouterParentProcessMessageHandler", () => { assert.calledOnce(config.router.forceAttribution); }); }); - describe("FORCE_WHATSNEW_PANEL action", () => { - it("default calls forceWNPanel", () => { - handler.handleMessage( - msg.FORCE_WHATSNEW_PANEL, - {}, - { browser: { ownerGlobal: {} } } - ); - assert.calledOnce(config.router.forceWNPanel); - assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} }); - }); - }); - describe("CLOSE_WHATSNEW_PANEL action", () => { - it("default calls closeWNPanel", () => { - handler.handleMessage( - msg.CLOSE_WHATSNEW_PANEL, - {}, - { browser: { ownerGlobal: {} } } - ); - assert.calledOnce(config.router.closeWNPanel); - assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} }); - }); - }); describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => { it("default calls forcePBWindow", () => { handler.handleMessage( diff --git a/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js index 3e91b657bc..cfeac77025 100644 --- a/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js +++ b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js @@ -1,10 +1,6 @@ import { _ToolbarBadgeHub } from "modules/ToolbarBadgeHub.sys.mjs"; import { GlobalOverrider } from "test/unit/utils"; import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; -import { - _ToolbarPanelHub, - ToolbarPanelHub, -} from "modules/ToolbarPanelHub.sys.mjs"; describe("ToolbarBadgeHub", () => { let sandbox; @@ -13,7 +9,6 @@ describe("ToolbarBadgeHub", () => { let fakeSendTelemetry; let isBrowserPrivateStub; let fxaMessage; - let whatsnewMessage; let fakeElement; let globals; let everyWindowStub; @@ -36,28 +31,6 @@ describe("ToolbarBadgeHub", () => { const onboardingMsgs = await OnboardingMessageProvider.getUntranslatedMessages(); fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE"); - whatsnewMessage = { - id: `WHATS_NEW_BADGE_71`, - template: "toolbar_badge", - content: { - delay: 1000, - target: "whats-new-menu-button", - action: { id: "show-whatsnew-button" }, - badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" }, - }, - priority: 1, - trigger: { id: "toolbarBadgeUpdate" }, - frequency: { - // Makes it so that we track impressions for this message while at the - // same time it can have unlimited impressions - lifetime: Infinity, - }, - // Never saw this message or saw it in the past 4 days or more recent - targeting: `isWhatsNewPanelEnabled && - (!messageImpressions['WHATS_NEW_BADGE_71'] || - (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 && - currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`, - }; fakeElement = { classList: { add: sandbox.stub(), @@ -93,7 +66,6 @@ describe("ToolbarBadgeHub", () => { setStringPrefStub = sandbox.stub(); requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn()); globals.set({ - ToolbarPanelHub, requestIdleCallback: requestIdleCallbackStub, EveryWindow: everyWindowStub, PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub }, @@ -139,16 +111,6 @@ describe("ToolbarBadgeHub", () => { assert.calledTwice(instance.messageRequest); }); - it("should add a pref observer", async () => { - await instance.init(sandbox.stub().resolves(), {}); - - assert.calledOnce(addObserverStub); - assert.calledWithExactly( - addObserverStub, - instance.prefs.WHATSNEW_TOOLBAR_PANEL, - instance - ); - }); }); describe("#uninit", () => { beforeEach(async () => { @@ -164,16 +126,6 @@ describe("ToolbarBadgeHub", () => { assert.calledOnce(clearTimeoutStub); assert.calledWithExactly(clearTimeoutStub, 2); }); - it("should remove the pref observer", () => { - instance.uninit(); - - assert.calledOnce(removeObserverStub); - assert.calledWithExactly( - removeObserverStub, - instance.prefs.WHATSNEW_TOOLBAR_PANEL, - instance - ); - }); }); describe("messageRequest", () => { let handleMessageRequestStub; @@ -293,66 +245,6 @@ describe("ToolbarBadgeHub", () => { instance.removeAllNotifications ); }); - it("should execute actions if they exist", () => { - sandbox.stub(instance, "executeAction"); - instance.addToolbarNotification(target, whatsnewMessage); - - assert.calledOnce(instance.executeAction); - assert.calledWithExactly(instance.executeAction, { - ...whatsnewMessage.content.action, - message_id: whatsnewMessage.id, - }); - }); - it("should create a description element", () => { - sandbox.stub(instance, "executeAction"); - instance.addToolbarNotification(target, whatsnewMessage); - - assert.calledOnce(fakeDocument.createElement); - assert.calledWithExactly(fakeDocument.createElement, "span"); - }); - it("should set description id to element and to button", () => { - sandbox.stub(instance, "executeAction"); - instance.addToolbarNotification(target, whatsnewMessage); - - assert.calledWithExactly( - fakeElement.setAttribute, - "id", - "toolbarbutton-notification-description" - ); - assert.calledWithExactly( - fakeElement.setAttribute, - "aria-labelledby", - `toolbarbutton-notification-description ${whatsnewMessage.content.target}` - ); - }); - it("should attach fluent id to description", () => { - sandbox.stub(instance, "executeAction"); - instance.addToolbarNotification(target, whatsnewMessage); - - assert.calledOnce(fakeDocument.l10n.setAttributes); - assert.calledWithExactly( - fakeDocument.l10n.setAttributes, - fakeElement, - whatsnewMessage.content.badgeDescription.string_id - ); - }); - it("should add an impression for the message", () => { - instance.addToolbarNotification(target, whatsnewMessage); - - assert.calledOnce(instance._addImpression); - assert.calledWithExactly(instance._addImpression, whatsnewMessage); - }); - it("should send an impression ping", async () => { - sandbox.stub(instance, "sendUserEventTelemetry"); - instance.addToolbarNotification(target, whatsnewMessage); - - assert.calledOnce(instance.sendUserEventTelemetry); - assert.calledWithExactly( - instance.sendUserEventTelemetry, - "IMPRESSION", - whatsnewMessage - ); - }); }); describe("registerBadgeNotificationListener", () => { let msg_no_delay; @@ -410,44 +302,6 @@ describe("ToolbarBadgeHub", () => { assert.calledOnce(everyWindowStub.unregisterCallback); assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); }); - it("should only call executeAction for 'update_action' messages", () => { - const stub = sandbox.stub(instance, "executeAction"); - const updateActionMsg = { ...msg_no_delay, template: "update_action" }; - - instance.registerBadgeNotificationListener(updateActionMsg); - - assert.notCalled(everyWindowStub.registerCallback); - assert.calledOnce(stub); - }); - }); - describe("executeAction", () => { - let blockMessageByIdStub; - beforeEach(async () => { - blockMessageByIdStub = sandbox.stub(); - await instance.init(sandbox.stub().resolves(), { - blockMessageById: blockMessageByIdStub, - }); - }); - it("should call ToolbarPanelHub.enableToolbarButton", () => { - const stub = sandbox.stub( - _ToolbarPanelHub.prototype, - "enableToolbarButton" - ); - - instance.executeAction({ id: "show-whatsnew-button" }); - - assert.calledOnce(stub); - }); - it("should call ToolbarPanelHub.enableAppmenuButton", () => { - const stub = sandbox.stub( - _ToolbarPanelHub.prototype, - "enableAppmenuButton" - ); - - instance.executeAction({ id: "show-whatsnew-button" }); - - assert.calledOnce(stub); - }); }); describe("removeToolbarNotification", () => { it("should remove the notification", () => { @@ -629,24 +483,4 @@ describe("ToolbarBadgeHub", () => { assert.propertyVal(ping.data, "event", "CLICK"); }); }); - describe("#observe", () => { - it("should make a message request when the whats new pref is changed", () => { - sandbox.stub(instance, "messageRequest"); - - instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL); - - assert.calledOnce(instance.messageRequest); - assert.calledWithExactly(instance.messageRequest, { - template: "toolbar_badge", - triggerId: "toolbarBadgeUpdate", - }); - }); - it("should not react to other pref changes", () => { - sandbox.stub(instance, "messageRequest"); - - instance.observe("", "", "foo"); - - assert.notCalled(instance.messageRequest); - }); - }); }); diff --git a/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js deleted file mode 100644 index 1755f62308..0000000000 --- a/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js +++ /dev/null @@ -1,760 +0,0 @@ -import { _ToolbarPanelHub } from "modules/ToolbarPanelHub.sys.mjs"; -import { GlobalOverrider } from "test/unit/utils"; -import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; - -describe("ToolbarPanelHub", () => { - let globals; - let sandbox; - let instance; - let everyWindowStub; - let fakeDocument; - let fakeWindow; - let fakeElementById; - let fakeElementByTagName; - let createdCustomElements = []; - let eventListeners = {}; - let addObserverStub; - let removeObserverStub; - let getBoolPrefStub; - let setBoolPrefStub; - let waitForInitializedStub; - let isBrowserPrivateStub; - let fakeSendTelemetry; - let getEarliestRecordedDateStub; - let getEventsByDateRangeStub; - let defaultSearchStub; - let scriptloaderStub; - let fakeRemoteL10n; - let getViewNodeStub; - - beforeEach(async () => { - sandbox = sinon.createSandbox(); - globals = new GlobalOverrider(); - instance = new _ToolbarPanelHub(); - waitForInitializedStub = sandbox.stub().resolves(); - fakeElementById = { - setAttribute: sandbox.stub(), - removeAttribute: sandbox.stub(), - querySelector: sandbox.stub().returns(null), - querySelectorAll: sandbox.stub().returns([]), - appendChild: sandbox.stub(), - addEventListener: sandbox.stub(), - hasAttribute: sandbox.stub(), - toggleAttribute: sandbox.stub(), - remove: sandbox.stub(), - removeChild: sandbox.stub(), - }; - fakeElementByTagName = { - setAttribute: sandbox.stub(), - removeAttribute: sandbox.stub(), - querySelector: sandbox.stub().returns(null), - querySelectorAll: sandbox.stub().returns([]), - appendChild: sandbox.stub(), - addEventListener: sandbox.stub(), - hasAttribute: sandbox.stub(), - toggleAttribute: sandbox.stub(), - remove: sandbox.stub(), - removeChild: sandbox.stub(), - }; - fakeDocument = { - getElementById: sandbox.stub().returns(fakeElementById), - getElementsByTagName: sandbox.stub().returns(fakeElementByTagName), - querySelector: sandbox.stub().returns({}), - createElement: tagName => { - const element = { - tagName, - classList: {}, - addEventListener: (ev, fn) => { - eventListeners[ev] = fn; - }, - appendChild: sandbox.stub(), - setAttribute: sandbox.stub(), - textContent: "", - }; - element.classList.add = sandbox.stub(); - element.classList.includes = className => - element.classList.add.firstCall.args[0] === className; - createdCustomElements.push(element); - return element; - }, - l10n: { - translateElements: sandbox.stub(), - translateFragment: sandbox.stub(), - formatMessages: sandbox.stub().resolves([{}]), - setAttributes: sandbox.stub(), - }, - }; - fakeWindow = { - // eslint-disable-next-line object-shorthand - DocumentFragment: function () { - return fakeElementById; - }, - document: fakeDocument, - browser: { - ownerDocument: fakeDocument, - }, - MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, - ownerGlobal: { - openLinkIn: sandbox.stub(), - gBrowser: "gBrowser", - }, - PanelUI: { - panel: fakeElementById, - whatsNewPanel: fakeElementById, - }, - customElements: { get: sandbox.stub() }, - }; - everyWindowStub = { - registerCallback: sandbox.stub(), - unregisterCallback: sandbox.stub(), - }; - scriptloaderStub = { loadSubScript: sandbox.stub() }; - addObserverStub = sandbox.stub(); - removeObserverStub = sandbox.stub(); - getBoolPrefStub = sandbox.stub(); - setBoolPrefStub = sandbox.stub(); - fakeSendTelemetry = sandbox.stub(); - isBrowserPrivateStub = sandbox.stub(); - getEarliestRecordedDateStub = sandbox.stub().returns( - // A random date that's not the current timestamp - new Date() - 500 - ); - getEventsByDateRangeStub = sandbox.stub().returns([]); - getViewNodeStub = sandbox.stub().returns(fakeElementById); - defaultSearchStub = { defaultEngine: { name: "DDG" } }; - fakeRemoteL10n = { - l10n: {}, - reloadL10n: sandbox.stub(), - createElement: sandbox - .stub() - .callsFake((doc, el) => fakeDocument.createElement(el)), - }; - globals.set({ - EveryWindow: everyWindowStub, - Services: { - ...Services, - prefs: { - addObserver: addObserverStub, - removeObserver: removeObserverStub, - getBoolPref: getBoolPrefStub, - setBoolPref: setBoolPrefStub, - }, - search: defaultSearchStub, - scriptloader: scriptloaderStub, - }, - PrivateBrowsingUtils: { - isBrowserPrivate: isBrowserPrivateStub, - }, - TrackingDBService: { - getEarliestRecordedDate: getEarliestRecordedDateStub, - getEventsByDateRange: getEventsByDateRangeStub, - }, - SpecialMessageActions: { - handleAction: sandbox.stub(), - }, - RemoteL10n: fakeRemoteL10n, - PanelMultiView: { - getViewNode: getViewNodeStub, - }, - }); - }); - afterEach(() => { - instance.uninit(); - sandbox.restore(); - globals.restore(); - eventListeners = {}; - createdCustomElements = []; - }); - it("should create an instance", () => { - assert.ok(instance); - }); - it("should enableAppmenuButton() on init() just once", async () => { - instance.enableAppmenuButton = sandbox.stub(); - - await instance.init(waitForInitializedStub, { getMessages: () => {} }); - await instance.init(waitForInitializedStub, { getMessages: () => {} }); - - assert.calledOnce(instance.enableAppmenuButton); - - instance.uninit(); - - await instance.init(waitForInitializedStub, { getMessages: () => {} }); - - assert.calledTwice(instance.enableAppmenuButton); - }); - it("should unregisterCallback on uninit()", () => { - instance.uninit(); - assert.calledTwice(everyWindowStub.unregisterCallback); - }); - describe("#maybeLoadCustomElement", () => { - it("should not load customElements a second time", () => { - instance.maybeLoadCustomElement({ customElements: new Map() }); - instance.maybeLoadCustomElement({ - customElements: new Map([["remote-text", true]]), - }); - - assert.calledOnce(scriptloaderStub.loadSubScript); - }); - }); - describe("#toggleWhatsNewPref", () => { - it("should call Services.prefs.setBoolPref() with the opposite value", () => { - let checkbox = {}; - let event = { target: checkbox }; - // checkbox starts false - checkbox.checked = false; - - // toggling the checkbox to set the value to true; - // Preferences.set() gets called before the checkbox changes, - // so we have to call it with the opposite value. - instance.toggleWhatsNewPref(event); - - assert.calledOnce(setBoolPrefStub); - assert.calledWith( - setBoolPrefStub, - "browser.messaging-system.whatsNewPanel.enabled", - !checkbox.checked - ); - }); - it("should report telemetry with the opposite value", () => { - let sendUserEventTelemetryStub = sandbox.stub( - instance, - "sendUserEventTelemetry" - ); - let event = { - target: { checked: true, ownerGlobal: fakeWindow }, - }; - - instance.toggleWhatsNewPref(event); - - assert.calledOnce(sendUserEventTelemetryStub); - const { args } = sendUserEventTelemetryStub.firstCall; - assert.equal(args[1], "WNP_PREF_TOGGLE"); - assert.propertyVal(args[3].value, "prefValue", false); - }); - }); - describe("#enableAppmenuButton", () => { - it("should registerCallback on enableAppmenuButton() if there are messages", async () => { - await instance.init(waitForInitializedStub, { - getMessages: sandbox.stub().resolves([{}, {}]), - }); - // init calls `enableAppmenuButton` - everyWindowStub.registerCallback.resetHistory(); - - await instance.enableAppmenuButton(); - - assert.calledOnce(everyWindowStub.registerCallback); - assert.calledWithExactly( - everyWindowStub.registerCallback, - "appMenu-whatsnew-button", - sinon.match.func, - sinon.match.func - ); - }); - it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => { - instance.init(waitForInitializedStub, { - getMessages: sandbox.stub().resolves([]), - }); - // init calls `enableAppmenuButton` - everyWindowStub.registerCallback.resetHistory(); - - await instance.enableAppmenuButton(); - - assert.notCalled(everyWindowStub.registerCallback); - }); - }); - describe("#disableAppmenuButton", () => { - it("should call the unregisterCallback", () => { - assert.notCalled(everyWindowStub.unregisterCallback); - - instance.disableAppmenuButton(); - - assert.calledOnce(everyWindowStub.unregisterCallback); - assert.calledWithExactly( - everyWindowStub.unregisterCallback, - "appMenu-whatsnew-button" - ); - }); - }); - describe("#enableToolbarButton", () => { - it("should registerCallback on enableToolbarButton if messages.length", async () => { - await instance.init(waitForInitializedStub, { - getMessages: sandbox.stub().resolves([{}, {}]), - }); - // init calls `enableAppmenuButton` - everyWindowStub.registerCallback.resetHistory(); - - await instance.enableToolbarButton(); - - assert.calledOnce(everyWindowStub.registerCallback); - assert.calledWithExactly( - everyWindowStub.registerCallback, - "whats-new-menu-button", - sinon.match.func, - sinon.match.func - ); - }); - it("should not registerCallback on enableToolbarButton if no messages", async () => { - await instance.init(waitForInitializedStub, { - getMessages: sandbox.stub().resolves([]), - }); - - await instance.enableToolbarButton(); - - assert.notCalled(everyWindowStub.registerCallback); - }); - }); - describe("Show/Hide functions", () => { - it("should unhide appmenu button on _showAppmenuButton()", async () => { - await instance._showAppmenuButton(fakeWindow); - - assert.equal(fakeElementById.hidden, false); - }); - it("should hide appmenu button on _hideAppmenuButton()", () => { - instance._hideAppmenuButton(fakeWindow); - assert.equal(fakeElementById.hidden, true); - }); - it("should not do anything if the window is closed", () => { - instance._hideAppmenuButton(fakeWindow, true); - assert.notCalled(global.PanelMultiView.getViewNode); - }); - it("should not throw if the element does not exist", () => { - let fn = instance._hideAppmenuButton.bind(null, { - browser: { ownerDocument: {} }, - }); - getViewNodeStub.returns(undefined); - assert.doesNotThrow(fn); - }); - it("should unhide toolbar button on _showToolbarButton()", async () => { - await instance._showToolbarButton(fakeWindow); - - assert.equal(fakeElementById.hidden, false); - }); - it("should hide toolbar button on _hideToolbarButton()", () => { - instance._hideToolbarButton(fakeWindow); - assert.equal(fakeElementById.hidden, true); - }); - }); - describe("#renderMessages", () => { - let getMessagesStub; - beforeEach(() => { - getMessagesStub = sandbox.stub(); - instance.init(waitForInitializedStub, { - getMessages: getMessagesStub, - sendTelemetry: fakeSendTelemetry, - }); - }); - it("should have correct state", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - - getMessagesStub.returns(messages); - const ev1 = sandbox.stub(); - ev1.withArgs("type").returns(1); // tracker - ev1.withArgs("count").returns(4); - const ev2 = sandbox.stub(); - ev2.withArgs("type").returns(4); // fingerprinter - ev2.withArgs("count").returns(3); - getEventsByDateRangeStub.returns([ - { getResultByName: ev1 }, - { getResultByName: ev2 }, - ]); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.propertyVal(instance.state.contentArguments, "trackerCount", 4); - assert.propertyVal( - instance.state.contentArguments, - "fingerprinterCount", - 3 - ); - }); - it("should render messages to the panel on renderMessages()", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - messages[0].content.link_text = { string_id: "link_text_id" }; - - getMessagesStub.returns(messages); - const ev1 = sandbox.stub(); - ev1.withArgs("type").returns(1); // tracker - ev1.withArgs("count").returns(4); - const ev2 = sandbox.stub(); - ev2.withArgs("type").returns(4); // fingerprinter - ev2.withArgs("count").returns(3); - getEventsByDateRangeStub.returns([ - { getResultByName: ev1 }, - { getResultByName: ev2 }, - ]); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - for (let message of messages) { - assert.ok( - fakeRemoteL10n.createElement.args.find( - ([, , args]) => args && args.classList === "whatsNew-message-title" - ) - ); - if (message.content.layout === "tracking-protections") { - assert.ok( - fakeRemoteL10n.createElement.args.find( - ([, , args]) => - args && args.classList === "whatsNew-message-subtitle" - ) - ); - } - if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") { - assert.ok( - fakeRemoteL10n.createElement.args.find( - ([, el, args]) => el === "h2" && args.content === 3 - ) - ); - } - assert.ok( - fakeRemoteL10n.createElement.args.find( - ([, , args]) => - args && args.classList === "whatsNew-message-content" - ) - ); - } - // Call the click handler to make coverage happy. - eventListeners.mouseup(); - assert.calledOnce(global.SpecialMessageActions.handleAction); - }); - it("should clear previous messages on 2nd renderMessages()", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - const removeStub = sandbox.stub(); - fakeElementById.querySelectorAll.onCall(0).returns([]); - fakeElementById.querySelectorAll - .onCall(1) - .returns([{ remove: removeStub }, { remove: removeStub }]); - - getMessagesStub.returns(messages); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.calledTwice(removeStub); - }); - it("should sort based on order field value", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => - m.template === "whatsnew_panel_message" && - m.content.published_date === 1560969794394 - ); - - messages.forEach(m => (m.content.title = m.order)); - - getMessagesStub.returns(messages); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - // Select the title elements that are supposed to be set to the same - // value as the `order` field of the message - const titleEls = fakeRemoteL10n.createElement.args - .filter( - ([, , args]) => args && args.classList === "whatsNew-message-title" - ) - .map(([, , args]) => args.content); - assert.deepEqual(titleEls, [1, 2, 3]); - }); - it("should accept string for image attributes", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.id === "WHATS_NEW_70_1" - ); - getMessagesStub.returns(messages); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - const imageEl = createdCustomElements.find(el => el.tagName === "img"); - assert.calledOnce(imageEl.setAttribute); - assert.calledWithExactly( - imageEl.setAttribute, - "alt", - "Firefox Send Logo" - ); - }); - it("should set state values as data-attribute", async () => { - const message = (await PanelTestProvider.getMessages()).find( - m => m.template === "whatsnew_panel_message" - ); - getMessagesStub.returns([message]); - instance.state.contentArguments = { foo: "foo", bar: "bar" }; - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - const [, , args] = fakeRemoteL10n.createElement.args.find( - ([, , elArgs]) => elArgs && elArgs.attributes - ); - assert.ok(args); - // Currently this.state.contentArguments has 8 different entries - assert.lengthOf(Object.keys(args.attributes), 8); - assert.equal( - args.attributes.searchEngineName, - defaultSearchStub.defaultEngine.name - ); - }); - it("should only render unique dates (no duplicates)", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - const uniqueDates = [ - ...new Set(messages.map(m => m.content.published_date)), - ]; - getMessagesStub.returns(messages); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - const dateElements = fakeRemoteL10n.createElement.args.filter( - ([, el, args]) => - el === "p" && args.classList === "whatsNew-message-date" - ); - assert.lengthOf(dateElements, uniqueDates.length); - }); - it("should listen for panelhidden and remove the toolbar button", async () => { - getMessagesStub.returns([]); - fakeDocument.getElementById - .withArgs("customizationui-widget-panel") - .returns(null); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.notCalled(fakeElementById.addEventListener); - }); - it("should attach doCommand cbs that handle user actions", async () => { - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - getMessagesStub.returns(messages); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - const messageEl = createdCustomElements.find( - el => - el.tagName === "div" && el.classList.includes("whatsNew-message-body") - ); - const anchorEl = createdCustomElements.find(el => el.tagName === "a"); - - assert.notCalled(global.SpecialMessageActions.handleAction); - - messageEl.doCommand(); - anchorEl.doCommand(); - - assert.calledTwice(global.SpecialMessageActions.handleAction); - }); - it("should listen for panelhidden and remove the toolbar button", async () => { - getMessagesStub.returns([]); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.calledOnce(fakeElementById.addEventListener); - assert.calledWithExactly( - fakeElementById.addEventListener, - "popuphidden", - sinon.match.func, - { - once: true, - } - ); - const [, cb] = fakeElementById.addEventListener.firstCall.args; - - assert.notCalled(everyWindowStub.unregisterCallback); - - cb(); - - assert.calledOnce(everyWindowStub.unregisterCallback); - assert.calledWithExactly( - everyWindowStub.unregisterCallback, - "whats-new-menu-button" - ); - }); - describe("#IMPRESSION", () => { - it("should dispatch a IMPRESSION for messages", async () => { - // means panel is triggered from the toolbar button - fakeElementById.hasAttribute.returns(true); - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - getMessagesStub.returns(messages); - const spy = sandbox.spy(instance, "sendUserEventTelemetry"); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.calledOnce(spy); - assert.calledOnce(fakeSendTelemetry); - assert.propertyVal( - spy.firstCall.args[2], - "id", - messages - .map(({ id }) => id) - .sort() - .join(",") - ); - }); - it("should dispatch a CLICK for clicking a message", async () => { - // means panel is triggered from the toolbar button - fakeElementById.hasAttribute.returns(true); - // Force to render the message - fakeElementById.querySelector.returns(null); - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - getMessagesStub.returns([messages[0]]); - const spy = sandbox.spy(instance, "sendUserEventTelemetry"); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.calledOnce(spy); - assert.calledOnce(fakeSendTelemetry); - - spy.resetHistory(); - - // Message click event listener cb - eventListeners.mouseup(); - - assert.calledOnce(spy); - assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]); - }); - it("should dispatch a IMPRESSION with toolbar_dropdown", async () => { - // means panel is triggered from the toolbar button - fakeElementById.hasAttribute.returns(true); - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - getMessagesStub.resolves(messages); - const spy = sandbox.spy(instance, "sendUserEventTelemetry"); - const panelPingId = messages - .map(({ id }) => id) - .sort() - .join(","); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.calledOnce(spy); - assert.calledWithExactly( - spy, - fakeWindow, - "IMPRESSION", - { - id: panelPingId, - }, - { - value: { - view: "toolbar_dropdown", - }, - } - ); - assert.calledOnce(fakeSendTelemetry); - const { - args: [dispatchPayload], - } = fakeSendTelemetry.lastCall; - assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); - assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); - assert.deepEqual(dispatchPayload.data.event_context, { - view: "toolbar_dropdown", - }); - }); - it("should dispatch a IMPRESSION with application_menu", async () => { - // means panel is triggered as a subview in the application menu - fakeElementById.hasAttribute.returns(false); - const messages = (await PanelTestProvider.getMessages()).filter( - m => m.template === "whatsnew_panel_message" - ); - getMessagesStub.resolves(messages); - const spy = sandbox.spy(instance, "sendUserEventTelemetry"); - const panelPingId = messages - .map(({ id }) => id) - .sort() - .join(","); - - await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); - - assert.calledOnce(spy); - assert.calledWithExactly( - spy, - fakeWindow, - "IMPRESSION", - { - id: panelPingId, - }, - { - value: { - view: "application_menu", - }, - } - ); - assert.calledOnce(fakeSendTelemetry); - const { - args: [dispatchPayload], - } = fakeSendTelemetry.lastCall; - assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); - assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); - assert.deepEqual(dispatchPayload.data.event_context, { - view: "application_menu", - }); - }); - }); - describe("#forceShowMessage", () => { - const panelSelector = "PanelUI-whatsNew-message-container"; - let removeMessagesSpy; - let renderMessagesStub; - let addEventListenerStub; - let messages; - let browser; - beforeEach(async () => { - messages = (await PanelTestProvider.getMessages()).find( - m => m.id === "WHATS_NEW_70_1" - ); - removeMessagesSpy = sandbox.spy(instance, "removeMessages"); - renderMessagesStub = sandbox.spy(instance, "renderMessages"); - addEventListenerStub = fakeElementById.addEventListener; - browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument }; - fakeElementById.querySelectorAll.returns([fakeElementById]); - }); - it("should call removeMessages when forcing a message to show", () => { - instance.forceShowMessage(browser, messages); - - assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); - }); - it("should call renderMessages when forcing a message to show", () => { - instance.forceShowMessage(browser, messages); - - assert.calledOnce(renderMessagesStub); - assert.calledWithExactly( - renderMessagesStub, - fakeWindow, - fakeDocument, - panelSelector, - { - force: true, - messages: Array.isArray(messages) ? messages : [messages], - } - ); - }); - it("should cleanup after the panel is hidden when forcing a message to show", () => { - instance.forceShowMessage(browser, messages); - - assert.calledOnce(addEventListenerStub); - assert.calledWithExactly( - addEventListenerStub, - "popuphidden", - sinon.match.func - ); - - const [, cb] = addEventListenerStub.firstCall.args; - // Reset the call count from the first `forceShowMessage` call - removeMessagesSpy.resetHistory(); - cb({ target: { ownerGlobal: fakeWindow } }); - - assert.calledOnce(removeMessagesSpy); - assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); - }); - it("should exit gracefully if called before a browser exists", () => { - instance.forceShowMessage(null, messages); - assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector); - }); - }); - }); -}); diff --git a/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx index 46d5704107..c5b0d09b39 100644 --- a/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx +++ b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx @@ -1,4 +1,7 @@ -import { ASRouterAdminInner } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { + ASRouterAdminInner, + toBinary, +} from "content-src/components/ASRouterAdmin/ASRouterAdmin"; import { ASRouterUtils } from "content-src/asrouter-utils"; import { GlobalOverrider } from "test/unit/utils"; import React from "react"; @@ -259,4 +262,43 @@ describe("ASRouterAdmin", () => { }); }); }); + describe("toBinary", () => { + // Bringing the 'fromBinary' function over from + // messagepreview to prove it works + function fromBinary(encoded) { + const binary = atob(decodeURIComponent(encoded)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return String.fromCharCode(...new Uint16Array(bytes.buffer)); + } + + it("correctly encodes a latin string", () => { + const testString = "Hi I am a test string"; + const expectedResult = + "SABpACAASQAgAGEAbQAgAGEAIAB0AGUAcwB0ACAAcwB0AHIAaQBuAGcA"; + + const encodedResult = toBinary(testString); + + assert.equal(encodedResult, expectedResult); + + const decodedResult = fromBinary(encodedResult); + + assert.equal(decodedResult, testString); + }); + + it("correctly encodes a non-latin string", () => { + const nonLatinString = "тестовое сообщение"; + const expectedResult = "QgQ1BEEEQgQ+BDIEPgQ1BCAAQQQ+BD4EMQRJBDUEPQQ4BDUE"; + + const encodedResult = toBinary("тестовое сообщение"); + + assert.equal(encodedResult, expectedResult); + + const decodedResult = fromBinary(encodedResult); + + assert.equal(decodedResult, nonLatinString); + }); + }); }); diff --git a/browser/components/asrouter/tests/unit/unit-entry.js b/browser/components/asrouter/tests/unit/unit-entry.js index f2046a81cb..2464b02c58 100644 --- a/browser/components/asrouter/tests/unit/unit-entry.js +++ b/browser/components/asrouter/tests/unit/unit-entry.js @@ -14,7 +14,7 @@ import FxMSCommonSchema from "../../content-src/schemas/FxMSCommon.schema.json"; import { MESSAGE_TYPE_LIST, MESSAGE_TYPE_HASH, -} from "modules/ActorConstants.sys.mjs"; +} from "modules/ActorConstants.mjs"; enzyme.configure({ adapter: new Adapter() }); diff --git a/browser/components/asrouter/tests/xpcshell/head.js b/browser/components/asrouter/tests/xpcshell/head.js index 0c6cec1ac8..fa361a00b9 100644 --- a/browser/components/asrouter/tests/xpcshell/head.js +++ b/browser/components/asrouter/tests/xpcshell/head.js @@ -81,10 +81,6 @@ async function makeValidators() { "resource://testing-common/UpdateAction.schema.json", { common: true } ), - whatsnew_panel_message: await schemaValidatorFor( - "resource://testing-common/WhatsNewMessage.schema.json", - { common: true } - ), feature_callout: await schemaValidatorFor( // For now, Feature Callout and Spotlight share a common schema "resource://testing-common/Spotlight.schema.json", diff --git a/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js index 3523355659..7e9892a595 100644 --- a/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js +++ b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js @@ -22,7 +22,6 @@ add_task(async function test_PanelTestProvider() { cfr_doorhanger: 1, milestone_message: 0, update_action: 1, - whatsnew_panel_message: 7, spotlight: 3, feature_callout: 1, pb_newtab: 2, diff --git a/browser/components/asrouter/yamscripts.yml b/browser/components/asrouter/yamscripts.yml index de16c269a4..e52063c911 100644 --- a/browser/components/asrouter/yamscripts.yml +++ b/browser/components/asrouter/yamscripts.yml @@ -19,6 +19,7 @@ scripts: lint: =>lint build: =>bundle:admin unit: karma start karma.mc.config.js + import: =>import-rollouts tddmc: karma start karma.mc.config.js --tdd diff --git a/browser/components/backup/.eslintrc.js b/browser/components/backup/.eslintrc.js deleted file mode 100644 index 9aafb4a214..0000000000 --- a/browser/components/backup/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -/* 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"; - -module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], -}; diff --git a/browser/components/backup/BackupResources.sys.mjs b/browser/components/backup/BackupResources.sys.mjs index 276fabefdf..ce7f53b10d 100644 --- a/browser/components/backup/BackupResources.sys.mjs +++ b/browser/components/backup/BackupResources.sys.mjs @@ -2,14 +2,28 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -// Remove this import after BackupResource is referenced elsewhere. -// eslint-disable-next-line no-unused-vars -import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; - /** * Classes exported here are registered as a resource that can be * backed up and restored in the BackupService. * * They must extend the BackupResource base class. */ -export {}; +import { AddonsBackupResource } from "resource:///modules/backup/AddonsBackupResource.sys.mjs"; +import { CookiesBackupResource } from "resource:///modules/backup/CookiesBackupResource.sys.mjs"; +import { CredentialsAndSecurityBackupResource } from "resource:///modules/backup/CredentialsAndSecurityBackupResource.sys.mjs"; +import { FormHistoryBackupResource } from "resource:///modules/backup/FormHistoryBackupResource.sys.mjs"; +import { MiscDataBackupResource } from "resource:///modules/backup/MiscDataBackupResource.sys.mjs"; +import { PlacesBackupResource } from "resource:///modules/backup/PlacesBackupResource.sys.mjs"; +import { PreferencesBackupResource } from "resource:///modules/backup/PreferencesBackupResource.sys.mjs"; +import { SessionStoreBackupResource } from "resource:///modules/backup/SessionStoreBackupResource.sys.mjs"; + +export { + AddonsBackupResource, + CookiesBackupResource, + CredentialsAndSecurityBackupResource, + FormHistoryBackupResource, + MiscDataBackupResource, + PlacesBackupResource, + PreferencesBackupResource, + SessionStoreBackupResource, +}; diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs index 853f4768ce..3521f315fd 100644 --- a/browser/components/backup/BackupService.sys.mjs +++ b/browser/components/backup/BackupService.sys.mjs @@ -2,7 +2,7 @@ * 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/. */ -import * as BackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; +import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; const lazy = {}; @@ -37,6 +37,13 @@ export class BackupService { #resources = new Map(); /** + * True if a backup is currently in progress. + * + * @type {boolean} + */ + #backupInProgress = false; + + /** * Returns a reference to a BackupService singleton. If this is the first time * that this getter is accessed, this causes the BackupService singleton to be * be instantiated. @@ -48,27 +55,130 @@ export class BackupService { if (this.#instance) { return this.#instance; } - this.#instance = new BackupService(BackupResources); + this.#instance = new BackupService(DefaultBackupResources); this.#instance.takeMeasurements(); return this.#instance; } /** + * Returns a reference to the BackupService singleton. If the singleton has + * not been initialized, an error is thrown. + * + * @static + * @returns {BackupService} + */ + static get() { + if (!this.#instance) { + throw new Error("BackupService not initialized"); + } + return this.#instance; + } + + /** * Create a BackupService instance. * - * @param {object} [backupResources=BackupResources] - Object containing BackupResource classes to associate with this service. + * @param {object} [backupResources=DefaultBackupResources] - Object containing BackupResource classes to associate with this service. */ - constructor(backupResources = BackupResources) { + constructor(backupResources = DefaultBackupResources) { lazy.logConsole.debug("Instantiated"); for (const resourceName in backupResources) { - let resource = BackupResources[resourceName]; + let resource = backupResources[resourceName]; this.#resources.set(resource.key, resource); } } /** + * Create a backup of the user's profile. + * + * @param {object} [options] + * Options for the backup. + * @param {string} [options.profilePath=PathUtils.profileDir] + * The path to the profile to backup. By default, this is the current + * profile. + * @returns {Promise<undefined>} + */ + async createBackup({ profilePath = PathUtils.profileDir } = {}) { + // createBackup does not allow re-entry or concurrent backups. + if (this.#backupInProgress) { + lazy.logConsole.warn("Backup attempt already in progress"); + return; + } + + this.#backupInProgress = true; + + try { + lazy.logConsole.debug(`Creating backup for profile at ${profilePath}`); + + // First, check to see if a `backups` directory already exists in the + // profile. + let backupDirPath = PathUtils.join(profilePath, "backups"); + lazy.logConsole.debug("Creating backups folder"); + + // ignoreExisting: true is the default, but we're being explicit that it's + // okay if this folder already exists. + await IOUtils.makeDirectory(backupDirPath, { ignoreExisting: true }); + + let stagingPath = await this.#prepareStagingFolder(backupDirPath); + + // Perform the backup for each resource. + for (let resourceClass of this.#resources.values()) { + try { + lazy.logConsole.debug( + `Backing up resource with key ${resourceClass.key}. ` + + `Requires encryption: ${resourceClass.requiresEncryption}` + ); + let resourcePath = PathUtils.join(stagingPath, resourceClass.key); + await IOUtils.makeDirectory(resourcePath); + + // `backup` on each BackupResource should return us a ManifestEntry + // that we eventually write to a JSON manifest file, but for now, + // we're just going to log it. + let manifestEntry = await new resourceClass().backup( + resourcePath, + profilePath + ); + lazy.logConsole.debug( + `Backup of resource with key ${resourceClass.key} completed`, + manifestEntry + ); + } catch (e) { + lazy.logConsole.error( + `Failed to backup resource: ${resourceClass.key}`, + e + ); + } + } + } finally { + this.#backupInProgress = false; + } + } + + /** + * Constructs the staging folder for the backup in the passed in backup + * folder. If a pre-existing staging folder exists, it will be cleared out. + * + * @param {string} backupDirPath + * The path to the backup folder. + * @returns {Promise<string>} + * The path to the empty staging folder. + */ + async #prepareStagingFolder(backupDirPath) { + let stagingPath = PathUtils.join(backupDirPath, "staging"); + lazy.logConsole.debug("Checking for pre-existing staging folder"); + if (await IOUtils.exists(stagingPath)) { + // A pre-existing staging folder exists. A previous backup attempt must + // have failed or been interrupted. We'll clear it out. + lazy.logConsole.warn("A pre-existing staging folder exists. Clearing."); + await IOUtils.remove(stagingPath, { recursive: true }); + } + await IOUtils.makeDirectory(stagingPath); + + return stagingPath; + } + + /** * Take measurements of the current profile state for Telemetry. * * @returns {Promise<undefined>} @@ -97,7 +207,14 @@ export class BackupService { // Measure the size of each file we are going to backup. for (let resourceClass of this.#resources.values()) { - await new resourceClass().measure(PathUtils.profileDir); + try { + await new resourceClass().measure(PathUtils.profileDir); + } catch (e) { + lazy.logConsole.error( + `Failed to measure for resource: ${resourceClass.key}`, + e + ); + } } } } diff --git a/browser/components/backup/content/debug.html b/browser/components/backup/content/debug.html new file mode 100644 index 0000000000..5d6517cf2a --- /dev/null +++ b/browser/components/backup/content/debug.html @@ -0,0 +1,46 @@ +<!-- 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 lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Profile backup debug tool</title> + + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + </head> + <body> + <header> + <h1>Profile backup debug tool</h1> + </header> + + <main> + <section> + <h2>State</h2> + <ol> + <li> + <input + type="checkbox" + preference="browser.backup.enabled" + />BackupService component enabled + </li> + <li> + <input + type="checkbox" + preference="browser.backup.log" + />BackupService debug logging enabled + </li> + </ol> + </section> + <section id="controls"> + <h2>Controls</h2> + <button id="create-backup">Create backup</button> + <button id="open-backup-folder">Open backups folder</button> + </section> + </main> + + <script src="chrome://global/content/preferencesBindings.js"></script> + <script src="chrome://browser/content/backup/debug.js"></script> + </body> +</html> diff --git a/browser/components/backup/content/debug.js b/browser/components/backup/content/debug.js new file mode 100644 index 0000000000..fd673818c0 --- /dev/null +++ b/browser/components/backup/content/debug.js @@ -0,0 +1,59 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +/* import-globals-from /toolkit/content/preferencesBindings.js */ + +Preferences.addAll([ + { id: "browser.backup.enabled", type: "bool" }, + { id: "browser.backup.log", type: "bool" }, +]); + +const { BackupService } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupService.sys.mjs" +); + +let DebugUI = { + init() { + let controls = document.querySelector("#controls"); + controls.addEventListener("click", this); + }, + + handleEvent(event) { + let target = event.target; + if (HTMLButtonElement.isInstance(event.target)) { + this.onButtonClick(target); + } + }, + + async onButtonClick(button) { + switch (button.id) { + case "create-backup": { + let service = BackupService.get(); + button.disabled = true; + await service.createBackup(); + button.disabled = false; + break; + } + case "open-backup-folder": { + let backupsDir = PathUtils.join(PathUtils.profileDir, "backups"); + + let nsLocalFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" + ); + + if (await IOUtils.exists(backupsDir)) { + new nsLocalFile(backupsDir).reveal(); + } else { + alert("backups folder doesn't exist yet"); + } + + break; + } + } + }, +}; + +DebugUI.init(); diff --git a/browser/components/backup/docs/backup-resources.rst b/browser/components/backup/docs/backup-resources.rst new file mode 100644 index 0000000000..4ead0d316d --- /dev/null +++ b/browser/components/backup/docs/backup-resources.rst @@ -0,0 +1,18 @@ +================================ +Backup Resources Reference +================================ + +A ``BackupResource`` is the base class used to represent a group of data within +a user profile that is logical to backup together. For example, the +``PlacesBackupResource`` represents both the ``places.sqlite`` SQLite database, +as well as the ``favicons.sqlite`` database. The ``AddonsBackupResource`` +represents not only the preferences for various addons, but also the XPI files +that those addons are defined in. + +Each ``BackupResource`` subclass is registered for use by the +``BackupService`` by adding it to the default set of exported classes in the +``BackupResources`` module in ``BackupResources.sys.mjs``. + +.. js:autoclass:: BackupResource + :members: + :private-members: diff --git a/browser/components/backup/docs/index.rst b/browser/components/backup/docs/index.rst index 1e201f8f1c..db9995dad2 100644 --- a/browser/components/backup/docs/index.rst +++ b/browser/components/backup/docs/index.rst @@ -11,3 +11,4 @@ into a single file that can be easily restored from. :maxdepth: 3 backup-service + backup-resources diff --git a/browser/components/backup/jar.mn b/browser/components/backup/jar.mn new file mode 100644 index 0000000000..7800962486 --- /dev/null +++ b/browser/components/backup/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: +#ifdef NIGHTLY_BUILD + content/browser/backup/debug.html (content/debug.html) + content/browser/backup/debug.js (content/debug.js) +#endif diff --git a/browser/components/backup/metrics.yaml b/browser/components/backup/metrics.yaml index 6d6a16a178..cf6f95ee75 100644 --- a/browser/components/backup/metrics.yaml +++ b/browser/components/backup/metrics.yaml @@ -28,3 +28,279 @@ browser.backup: - mconley@mozilla.com expires: never telemetry_mirror: BROWSER_BACKUP_PROF_D_DISK_SPACE + + places_size: + type: quantity + unit: kilobyte + description: > + The total file size of the places.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_PLACES_SIZE + + favicons_size: + type: quantity + unit: kilobyte + description: > + The total file size of the favicons.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_FAVICONS_SIZE + + credentials_data_size: + type: quantity + unit: kilobyte + description: > + The total size of logins, payment method, and form autofill related files + in the current profile directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_CREDENTIALS_DATA_SIZE + + security_data_size: + type: quantity + unit: kilobyte + description: > + The total size of files needed for NSS initialization parameters and security + certificate settings in the current profile directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_SECURITY_DATA_SIZE + + preferences_size: + type: quantity + unit: kilobyte + description: > + The total size of files relating to user preferences and permissions in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883739 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883739 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_PREFERENCES_SIZE + + misc_data_size: + type: quantity + unit: kilobyte + description: > + The total size of files for telemetry, site storage, media device origin mapping, + chrome privileged IndexedDB databases, and Mozilla Accounts in the current profile directory, + rounded to the nearest tenth kilobyte. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883747 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887746 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883747 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887746 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_MISC_DATA_SIZE + + cookies_size: + type: quantity + unit: kilobyte + description: > + The total file size of the cookies.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_COOKIES_SIZE + + form_history_size: + type: quantity + unit: kilobyte + description: > + The file size of the formhistory.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_FORM_HISTORY_SIZE + + session_store_backups_directory_size: + type: quantity + unit: kilobyte + description: > + The total size of the session store backups directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_SESSION_STORE_BACKUPS_DIRECTORY_SIZE + + session_store_size: + type: quantity + unit: kilobyte + description: > + The size of uncompressed session store json, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_SESSION_STORE_SIZE + + extensions_json_size: + type: quantity + unit: kilobyte + description: > + The total file size of the current profiles extensions metadata files, + rounded to the nearest 10 kilobytes. + Files included are: + - extensions.json + - extension-settings.json + - extension-preferences.json + - addonStartup.json.lz4 + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSIONS_JSON_SIZE + + extension_store_permissions_data_size: + type: quantity + unit: kilobyte + description: > + The file size of the current profiles extension-store-permissions/data.safe.bin + file, rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSION_STORE_PERMISSIONS_DATA_SIZE + + storage_sync_size: + type: quantity + unit: kilobyte + description: > + The file size of the current profiles storage-sync-v2.sqlite db, + rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_STORAGE_SYNC_SIZE + + browser_extension_data_size: + type: quantity + unit: kilobyte + description: > + The total size of the current profiles storage.local legacy JSON backend + in the browser-extension-data directory, rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_BROWSER_EXTENSION_DATA_SIZE + + extensions_xpi_directory_size: + type: quantity + unit: kilobyte + description: > + The total size of the current profiles extensions directory, + rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSIONS_XPI_DIRECTORY_SIZE + + extensions_storage_size: + type: quantity + unit: kilobyte + description: > + The total size of all extensions storage directories, + rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSIONS_STORAGE_SIZE diff --git a/browser/components/backup/moz.build b/browser/components/backup/moz.build index 0ea7d66b7d..be548ce81f 100644 --- a/browser/components/backup/moz.build +++ b/browser/components/backup/moz.build @@ -7,6 +7,8 @@ with Files("**"): BUG_COMPONENT = ("Firefox", "Profiles") +JAR_MANIFESTS += ["jar.mn"] + SPHINX_TREES["docs"] = "docs" XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] @@ -14,5 +16,13 @@ XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] EXTRA_JS_MODULES.backup += [ "BackupResources.sys.mjs", "BackupService.sys.mjs", + "resources/AddonsBackupResource.sys.mjs", "resources/BackupResource.sys.mjs", + "resources/CookiesBackupResource.sys.mjs", + "resources/CredentialsAndSecurityBackupResource.sys.mjs", + "resources/FormHistoryBackupResource.sys.mjs", + "resources/MiscDataBackupResource.sys.mjs", + "resources/PlacesBackupResource.sys.mjs", + "resources/PreferencesBackupResource.sys.mjs", + "resources/SessionStoreBackupResource.sys.mjs", ] diff --git a/browser/components/backup/resources/AddonsBackupResource.sys.mjs b/browser/components/backup/resources/AddonsBackupResource.sys.mjs new file mode 100644 index 0000000000..83b97ed2f2 --- /dev/null +++ b/browser/components/backup/resources/AddonsBackupResource.sys.mjs @@ -0,0 +1,100 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Backup for addons and extensions files and data. + */ +export class AddonsBackupResource extends BackupResource { + static get key() { + return "addons"; + } + + static get requiresEncryption() { + return false; + } + + async measure(profilePath = PathUtils.profileDir) { + // Report the total size of the extension json files. + const jsonFiles = [ + "extensions.json", + "extension-settings.json", + "extension-preferences.json", + "addonStartup.json.lz4", + ]; + let extensionsJsonSize = 0; + for (const filePath of jsonFiles) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + extensionsJsonSize += resourceSize; + } + } + Glean.browserBackup.extensionsJsonSize.set(extensionsJsonSize); + + // Report the size of permissions store data, if present. + let extensionStorePermissionsDataPath = PathUtils.join( + profilePath, + "extension-store-permissions", + "data.safe.bin" + ); + let extensionStorePermissionsDataSize = await BackupResource.getFileSize( + extensionStorePermissionsDataPath + ); + if (Number.isInteger(extensionStorePermissionsDataSize)) { + Glean.browserBackup.extensionStorePermissionsDataSize.set( + extensionStorePermissionsDataSize + ); + } + + // Report the size of extensions storage sync database. + let storageSyncPath = PathUtils.join(profilePath, "storage-sync-v2.sqlite"); + let storageSyncSize = await BackupResource.getFileSize(storageSyncPath); + Glean.browserBackup.storageSyncSize.set(storageSyncSize); + + // Report the total size of XPI files in the extensions directory. + let extensionsXpiDirectoryPath = PathUtils.join(profilePath, "extensions"); + let extensionsXpiDirectorySize = await BackupResource.getDirectorySize( + extensionsXpiDirectoryPath, + { + shouldExclude: (filePath, fileType) => + fileType !== "regular" || !filePath.endsWith(".xpi"), + } + ); + Glean.browserBackup.extensionsXpiDirectorySize.set( + extensionsXpiDirectorySize + ); + + // Report the total size of the browser extension data. + let browserExtensionDataPath = PathUtils.join( + profilePath, + "browser-extension-data" + ); + let browserExtensionDataSize = await BackupResource.getDirectorySize( + browserExtensionDataPath + ); + Glean.browserBackup.browserExtensionDataSize.set(browserExtensionDataSize); + + // Report the size of all moz-extension IndexedDB databases. + let defaultStoragePath = PathUtils.join(profilePath, "storage", "default"); + let extensionsStorageSize = await BackupResource.getDirectorySize( + defaultStoragePath, + { + shouldExclude: (filePath, _fileType, parentPath) => { + if ( + parentPath == defaultStoragePath && + !PathUtils.filename(filePath).startsWith("moz-extension") + ) { + return true; + } + return false; + }, + } + ); + if (Number.isInteger(extensionsStorageSize)) { + Glean.browserBackup.extensionsStorageSize.set(extensionsStorageSize); + } + } +} diff --git a/browser/components/backup/resources/BackupResource.sys.mjs b/browser/components/backup/resources/BackupResource.sys.mjs index bde3f0669c..d851eb5199 100644 --- a/browser/components/backup/resources/BackupResource.sys.mjs +++ b/browser/components/backup/resources/BackupResource.sys.mjs @@ -3,7 +3,19 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ // Convert from bytes to kilobytes (not kibibytes). -const BYTES_IN_KB = 1000; +export const BYTES_IN_KB = 1000; + +/** + * Convert bytes to the nearest 10th kilobyte to make the measurements fuzzier. + * + * @param {number} bytes - size in bytes. + * @returns {number} - size in kilobytes rounded to the nearest 10th kilobyte. + */ +export function bytesToFuzzyKilobytes(bytes) { + let sizeInKb = Math.ceil(bytes / BYTES_IN_KB); + let nearestTenthKb = Math.round(sizeInKb / 10) * 10; + return Math.max(nearestTenthKb, 1); +} /** * An abstract class representing a set of data within a user profile @@ -23,6 +35,21 @@ export class BackupResource { } /** + * This must be overridden to return a boolean indicating whether the + * resource requires encryption when being backed up. Encryption should be + * required for particularly sensitive data, such as passwords / credentials, + * cookies, or payment methods. If you're not sure, talk to someone from the + * Privacy team. + * + * @type {boolean} + */ + static get requiresEncryption() { + throw new Error( + "BackupResource::requiresEncryption needs to be overridden." + ); + } + + /** * Get the size of a file. * * @param {string} filePath - path to a file. @@ -40,21 +67,25 @@ export class BackupResource { return null; } - let sizeInKb = Math.ceil(size / BYTES_IN_KB); - // Make the measurement fuzzier by rounding to the nearest 10kb. - let nearestTenthKb = Math.round(sizeInKb / 10) * 10; + let nearestTenthKb = bytesToFuzzyKilobytes(size); - return Math.max(nearestTenthKb, 1); + return nearestTenthKb; } /** * Get the total size of a directory. * * @param {string} directoryPath - path to a directory. + * @param {object} options - A set of additional optional parameters. + * @param {Function} [options.shouldExclude] - an optional callback which based on file path and file type should return true + * if the file should be excluded from the computed directory size. * @returns {Promise<number|null>} - the size of all descendants of the directory in kilobytes, or null if the * directory does not exist, the path is not a directory or the size is unknown. */ - static async getDirectorySize(directoryPath) { + static async getDirectorySize( + directoryPath, + { shouldExclude = () => false } = {} + ) { if (!(await IOUtils.exists(directoryPath))) { return null; } @@ -75,15 +106,20 @@ export class BackupResource { childFilePath ); + if (shouldExclude(childFilePath, childType, directoryPath)) { + continue; + } + if (childSize >= 0) { - let sizeInKb = Math.ceil(childSize / BYTES_IN_KB); - // Make the measurement fuzzier by rounding to the nearest 10kb. - let nearestTenthKb = Math.round(sizeInKb / 10) * 10; - size += Math.max(nearestTenthKb, 1); + let nearestTenthKb = bytesToFuzzyKilobytes(childSize); + + size += nearestTenthKb; } if (childType == "directory") { - let childDirectorySize = await this.getDirectorySize(childFilePath); + let childDirectorySize = await this.getDirectorySize(childFilePath, { + shouldExclude, + }); if (Number.isInteger(childDirectorySize)) { size += childDirectorySize; } @@ -106,4 +142,29 @@ export class BackupResource { async measure(profilePath) { throw new Error("BackupResource::measure needs to be overridden."); } + + /** + * Perform a safe copy of the resource(s) and write them into the backup + * database. The Promise should resolve with an object that can be serialized + * to JSON, as it will be written to the manifest file. This same object will + * be deserialized and passed to restore() when restoring the backup. This + * object can be null if no additional information is needed to restore the + * backup. + * + * @param {string} stagingPath + * The path to the staging folder where copies of the datastores for this + * BackupResource should be written to. + * @param {string} [profilePath=null] + * This is null if the backup is being run on the currently running user + * profile. If, however, the backup is being run on a different user profile + * (for example, it's being run from a BackgroundTask on a user profile that + * just shut down, or during test), then this is a string set to that user + * profile path. + * + * @returns {Promise<object|null>} + */ + // eslint-disable-next-line no-unused-vars + async backup(stagingPath, profilePath = null) { + throw new Error("BackupResource::backup must be overridden"); + } } diff --git a/browser/components/backup/resources/CookiesBackupResource.sys.mjs b/browser/components/backup/resources/CookiesBackupResource.sys.mjs new file mode 100644 index 0000000000..8b988fd532 --- /dev/null +++ b/browser/components/backup/resources/CookiesBackupResource.sys.mjs @@ -0,0 +1,25 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Class representing Cookies database within a user profile. + */ +export class CookiesBackupResource extends BackupResource { + static get key() { + return "cookies"; + } + + static get requiresEncryption() { + return true; + } + + async measure(profilePath = PathUtils.profileDir) { + let cookiesDBPath = PathUtils.join(profilePath, "cookies.sqlite"); + let cookiesSize = await BackupResource.getFileSize(cookiesDBPath); + + Glean.browserBackup.cookiesSize.set(cookiesSize); + } +} diff --git a/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs b/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs new file mode 100644 index 0000000000..89069de826 --- /dev/null +++ b/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs @@ -0,0 +1,53 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Class representing files needed for logins, payment methods and form autofill within a user profile. + */ +export class CredentialsAndSecurityBackupResource extends BackupResource { + static get key() { + return "credentials_and_security"; + } + + static get requiresEncryption() { + return true; + } + + async measure(profilePath = PathUtils.profileDir) { + const securityFiles = ["cert9.db", "pkcs11.txt"]; + let securitySize = 0; + + for (let filePath of securityFiles) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + securitySize += resourceSize; + } + } + + Glean.browserBackup.securityDataSize.set(securitySize); + + const credentialsFiles = [ + "key4.db", + "logins.json", + "logins-backup.json", + "autofill-profiles.json", + "credentialstate.sqlite", + "signedInUser.json", + ]; + let credentialsSize = 0; + + for (let filePath of credentialsFiles) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + credentialsSize += resourceSize; + } + } + + Glean.browserBackup.credentialsDataSize.set(credentialsSize); + } +} diff --git a/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs b/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs new file mode 100644 index 0000000000..cb314eb34d --- /dev/null +++ b/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs @@ -0,0 +1,25 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Class representing Form history database within a user profile. + */ +export class FormHistoryBackupResource extends BackupResource { + static get key() { + return "formhistory"; + } + + static get requiresEncryption() { + return false; + } + + async measure(profilePath = PathUtils.profileDir) { + let formHistoryDBPath = PathUtils.join(profilePath, "formhistory.sqlite"); + let formHistorySize = await BackupResource.getFileSize(formHistoryDBPath); + + Glean.browserBackup.formHistorySize.set(formHistorySize); + } +} diff --git a/browser/components/backup/resources/MiscDataBackupResource.sys.mjs b/browser/components/backup/resources/MiscDataBackupResource.sys.mjs new file mode 100644 index 0000000000..97224f0e31 --- /dev/null +++ b/browser/components/backup/resources/MiscDataBackupResource.sys.mjs @@ -0,0 +1,101 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +/** + * Class representing miscellaneous files for telemetry, site storage, + * media device origin mapping, chrome privileged IndexedDB databases, + * and Mozilla Accounts within a user profile. + */ +export class MiscDataBackupResource extends BackupResource { + static get key() { + return "miscellaneous"; + } + + static get requiresEncryption() { + return false; + } + + async backup(stagingPath, profilePath = PathUtils.profileDir) { + const files = [ + "times.json", + "enumerate_devices.txt", + "SiteSecurityServiceState.bin", + ]; + + for (let fileName of files) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + if (await IOUtils.exists(sourcePath)) { + await IOUtils.copy(sourcePath, destPath, { recursive: true }); + } + } + + const sqliteDatabases = ["protections.sqlite"]; + + for (let fileName of sqliteDatabases) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + let connection; + + try { + connection = await lazy.Sqlite.openConnection({ + path: sourcePath, + readOnly: true, + }); + + await connection.backup(destPath); + } finally { + await connection.close(); + } + } + + // Bug 1890585 - we don't currently have the ability to copy the + // chrome-privileged IndexedDB databases under storage/permanent/chrome, so + // we'll just skip that for now. + + return null; + } + + async measure(profilePath = PathUtils.profileDir) { + const files = [ + "times.json", + "enumerate_devices.txt", + "protections.sqlite", + "SiteSecurityServiceState.bin", + ]; + + let fullSize = 0; + + for (let filePath of files) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + fullSize += resourceSize; + } + } + + let chromeIndexedDBDirPath = PathUtils.join( + profilePath, + "storage", + "permanent", + "chrome" + ); + let chromeIndexedDBDirSize = await BackupResource.getDirectorySize( + chromeIndexedDBDirPath + ); + if (Number.isInteger(chromeIndexedDBDirSize)) { + fullSize += chromeIndexedDBDirSize; + } + + Glean.browserBackup.miscDataSize.set(fullSize); + } +} diff --git a/browser/components/backup/resources/PlacesBackupResource.sys.mjs b/browser/components/backup/resources/PlacesBackupResource.sys.mjs new file mode 100644 index 0000000000..1955406f51 --- /dev/null +++ b/browser/components/backup/resources/PlacesBackupResource.sys.mjs @@ -0,0 +1,91 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isBrowsingHistoryEnabled", + "places.history.enabled", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isSanitizeOnShutdownEnabled", + "privacy.sanitize.sanitizeOnShutdown", + false +); + +/** + * Class representing Places database related files within a user profile. + */ +export class PlacesBackupResource extends BackupResource { + static get key() { + return "places"; + } + + static get requiresEncryption() { + return false; + } + + async backup(stagingPath, profilePath = PathUtils.profileDir) { + const sqliteDatabases = ["places.sqlite", "favicons.sqlite"]; + let canBackupHistory = + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing && + !lazy.isSanitizeOnShutdownEnabled && + lazy.isBrowsingHistoryEnabled; + + /** + * Do not backup places.sqlite and favicons.sqlite if users have history disabled, want history cleared on shutdown or are using permanent private browsing mode. + * Instead, export all existing bookmarks to a compressed JSON file that we can read when restoring the backup. + */ + if (!canBackupHistory) { + let bookmarksBackupFile = PathUtils.join( + stagingPath, + "bookmarks.jsonlz4" + ); + await lazy.BookmarkJSONUtils.exportToFile(bookmarksBackupFile, { + compress: true, + }); + return { bookmarksOnly: true }; + } + + for (let fileName of sqliteDatabases) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + let connection; + + try { + connection = await lazy.Sqlite.openConnection({ + path: sourcePath, + readOnly: true, + }); + + await connection.backup(destPath); + } finally { + await connection.close(); + } + } + return null; + } + + async measure(profilePath = PathUtils.profileDir) { + let placesDBPath = PathUtils.join(profilePath, "places.sqlite"); + let faviconsDBPath = PathUtils.join(profilePath, "favicons.sqlite"); + let placesDBSize = await BackupResource.getFileSize(placesDBPath); + let faviconsDBSize = await BackupResource.getFileSize(faviconsDBPath); + + Glean.browserBackup.placesSize.set(placesDBSize); + Glean.browserBackup.faviconsSize.set(faviconsDBSize); + } +} diff --git a/browser/components/backup/resources/PreferencesBackupResource.sys.mjs b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs new file mode 100644 index 0000000000..012c0bf91e --- /dev/null +++ b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs @@ -0,0 +1,98 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; +import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs"; + +/** + * Class representing files that modify preferences and permissions within a user profile. + */ +export class PreferencesBackupResource extends BackupResource { + static get key() { + return "preferences"; + } + + static get requiresEncryption() { + return false; + } + + async backup(stagingPath, profilePath = PathUtils.profileDir) { + // These are files that can be simply copied into the staging folder using + // IOUtils.copy. + const simpleCopyFiles = [ + "xulstore.json", + "containers.json", + "handlers.json", + "search.json.mozlz4", + "user.js", + "chrome", + ]; + + for (let fileName of simpleCopyFiles) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + if (await IOUtils.exists(sourcePath)) { + await IOUtils.copy(sourcePath, destPath, { recursive: true }); + } + } + + const sqliteDatabases = ["permissions.sqlite", "content-prefs.sqlite"]; + + for (let fileName of sqliteDatabases) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + let connection; + + try { + connection = await Sqlite.openConnection({ + path: sourcePath, + }); + + await connection.backup(destPath); + } finally { + await connection.close(); + } + } + + // prefs.js is a special case - we have a helper function to flush the + // current prefs state to disk off of the main thread. + let prefsDestPath = PathUtils.join(stagingPath, "prefs.js"); + let prefsDestFile = await IOUtils.getFile(prefsDestPath); + await Services.prefs.backupPrefFile(prefsDestFile); + + return null; + } + + async measure(profilePath = PathUtils.profileDir) { + const files = [ + "prefs.js", + "xulstore.json", + "permissions.sqlite", + "content-prefs.sqlite", + "containers.json", + "handlers.json", + "search.json.mozlz4", + "user.js", + ]; + let fullSize = 0; + + for (let filePath of files) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + fullSize += resourceSize; + } + } + + const chromeDirectoryPath = PathUtils.join(profilePath, "chrome"); + let chromeDirectorySize = await BackupResource.getDirectorySize( + chromeDirectoryPath + ); + if (Number.isInteger(chromeDirectorySize)) { + fullSize += chromeDirectorySize; + } + + Glean.browserBackup.preferencesSize.set(fullSize); + } +} diff --git a/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs new file mode 100644 index 0000000000..fa5dcca848 --- /dev/null +++ b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs @@ -0,0 +1,53 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { + BackupResource, + bytesToFuzzyKilobytes, +} from "resource:///modules/backup/BackupResource.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + * Class representing Session store related files within a user profile. + */ +export class SessionStoreBackupResource extends BackupResource { + static get key() { + return "sessionstore"; + } + + static get requiresEncryption() { + // Session store data does not require encryption, but if encryption is + // disabled, then session cookies will be cleared from the backup before + // writing it to the disk. + return false; + } + + async measure(profilePath = PathUtils.profileDir) { + // Get the current state of the session store JSON and + // measure it's uncompressed size. + let sessionStoreJson = lazy.SessionStore.getCurrentState(true); + let sessionStoreSize = new TextEncoder().encode( + JSON.stringify(sessionStoreJson) + ).byteLength; + let sessionStoreNearestTenthKb = bytesToFuzzyKilobytes(sessionStoreSize); + + Glean.browserBackup.sessionStoreSize.set(sessionStoreNearestTenthKb); + + let sessionStoreBackupsDirectoryPath = PathUtils.join( + profilePath, + "sessionstore-backups" + ); + let sessionStoreBackupsDirectorySize = + await BackupResource.getDirectorySize(sessionStoreBackupsDirectoryPath); + + Glean.browserBackup.sessionStoreBackupsDirectorySize.set( + sessionStoreBackupsDirectorySize + ); + } +} diff --git a/browser/components/backup/tests/xpcshell/head.js b/browser/components/backup/tests/xpcshell/head.js new file mode 100644 index 0000000000..2402870a13 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/head.js @@ -0,0 +1,167 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { BackupService } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupService.sys.mjs" +); + +const { BackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupResource.sys.mjs" +); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const { Sqlite } = ChromeUtils.importESModule( + "resource://gre/modules/Sqlite.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const BYTES_IN_KB = 1000; + +do_get_profile(); + +/** + * Some fake backup resource classes to test with. + */ +class FakeBackupResource1 extends BackupResource { + static get key() { + return "fake1"; + } + static get requiresEncryption() { + return false; + } +} + +/** + * Another fake backup resource class to test with. + */ +class FakeBackupResource2 extends BackupResource { + static get key() { + return "fake2"; + } + static get requiresEncryption() { + return true; + } +} + +/** + * Yet another fake backup resource class to test with. + */ +class FakeBackupResource3 extends BackupResource { + static get key() { + return "fake3"; + } + static get requiresEncryption() { + return false; + } +} + +/** + * Create a file of a given size in kilobytes. + * + * @param {string} path the path where the file will be created. + * @param {number} sizeInKB size file in Kilobytes. + * @returns {Promise<undefined>} + */ +async function createKilobyteSizedFile(path, sizeInKB) { + let bytes = new Uint8Array(sizeInKB * BYTES_IN_KB); + await IOUtils.write(path, bytes); +} + +/** + * @typedef {object} TestFileObject + * @property {(string|Array.<string>)} path + * The relative path of the file. It can be a string or an array of strings + * in the event that directories need to be created. For example, this is + * an array of valid TestFileObjects. + * + * [ + * { path: "file1.txt" }, + * { path: ["dir1", "file2.txt"] }, + * { path: ["dir2", "dir3", "file3.txt"], sizeInKB: 25 }, + * { path: "file4.txt" }, + * ] + * + * @property {number} [sizeInKB=10] + * The size of the created file in kilobytes. Defaults to 10. + */ + +/** + * Easily creates a series of test files and directories under parentPath. + * + * @param {string} parentPath + * The path to the parent directory where the files will be created. + * @param {TestFileObject[]} testFilesArray + * An array of TestFileObjects describing what test files to create within + * the parentPath. + * @see TestFileObject + * @returns {Promise<undefined>} + */ +async function createTestFiles(parentPath, testFilesArray) { + for (let { path, sizeInKB } of testFilesArray) { + if (Array.isArray(path)) { + // Make a copy of the array of path elements, chopping off the last one. + // We'll assume the unchopped items are directories, and make sure they + // exist first. + let folders = path.slice(0, -1); + await IOUtils.getDirectory(PathUtils.join(parentPath, ...folders)); + } + + if (sizeInKB === undefined) { + sizeInKB = 10; + } + + // This little piece of cleverness coerces a string into an array of one + // if path is a string, or just leaves it alone if it's already an array. + let filePath = PathUtils.join(parentPath, ...[].concat(path)); + await createKilobyteSizedFile(filePath, sizeInKB); + } +} + +/** + * Checks that files exist within a particular folder. The filesize is not + * checked. + * + * @param {string} parentPath + * The path to the parent directory where the files should exist. + * @param {TestFileObject[]} testFilesArray + * An array of TestFileObjects describing what test files to search for within + * parentPath. + * @see TestFileObject + * @returns {Promise<undefined>} + */ +async function assertFilesExist(parentPath, testFilesArray) { + for (let { path } of testFilesArray) { + let copiedFileName = PathUtils.join(parentPath, ...[].concat(path)); + Assert.ok( + await IOUtils.exists(copiedFileName), + `${copiedFileName} should exist in the staging folder` + ); + } +} + +/** + * Remove a file or directory at a path if it exists and files are unlocked. + * + * @param {string} path path to remove. + */ +async function maybeRemovePath(path) { + try { + await IOUtils.remove(path, { ignoreAbsent: true, recursive: true }); + } catch (error) { + // Sometimes remove() throws when the file is not unlocked soon + // enough. + if (error.name != "NS_ERROR_FILE_IS_LOCKED") { + // Ignoring any errors, as the temp folder will be cleaned up. + console.error(error); + } + } +} diff --git a/browser/components/backup/tests/xpcshell/test_BrowserResource.js b/browser/components/backup/tests/xpcshell/test_BackupResource.js index 23c8e077a5..6623f4cd77 100644 --- a/browser/components/backup/tests/xpcshell/test_BrowserResource.js +++ b/browser/components/backup/tests/xpcshell/test_BackupResource.js @@ -3,16 +3,12 @@ http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; -const { BackupResource } = ChromeUtils.importESModule( +const { bytesToFuzzyKilobytes } = ChromeUtils.importESModule( "resource:///modules/backup/BackupResource.sys.mjs" ); const EXPECTED_KILOBYTES_FOR_XULSTORE = 1; -add_setup(() => { - do_get_profile(); -}); - /** * Tests that BackupService.getFileSize will get the size of a file in kilobytes. */ @@ -35,7 +31,7 @@ add_task(async function test_getFileSize() { }); /** - * Tests that BackupService.getFileSize will get the total size of all the files in a directory and it's children in kilobytes. + * Tests that BackupService.getDirectorySize will get the total size of all the files in a directory and it's children in kilobytes. */ add_task(async function test_getDirectorySize() { let file = do_get_file("data/test_xulstore.json"); @@ -61,3 +57,21 @@ add_task(async function test_getDirectorySize() { await IOUtils.remove(testDir, { recursive: true }); }); + +/** + * Tests that bytesToFuzzyKilobytes will convert bytes to kilobytes + * and round up to the nearest tenth kilobyte. + */ +add_task(async function test_bytesToFuzzyKilobytes() { + let largeSize = bytesToFuzzyKilobytes(1234000); + + Assert.equal( + largeSize, + 1230, + "1234 bytes is rounded up to the nearest tenth kilobyte, 1230" + ); + + let smallSize = bytesToFuzzyKilobytes(3); + + Assert.equal(smallSize, 1, "Sizes under 10 kilobytes return 1 kilobyte"); +}); diff --git a/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js b/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js new file mode 100644 index 0000000000..e57dd50cd3 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MiscDataBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/MiscDataBackupResource.sys.mjs" +); + +/** + * Tests that we can measure miscellaneous files in the profile directory. + */ +add_task(async function test_measure() { + Services.fog.testResetFOG(); + + const EXPECTED_MISC_KILOBYTES_SIZE = 241; + const tempDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-measurement-test" + ); + + const mockFiles = [ + { path: "times.json", sizeInKB: 5 }, + { path: "enumerate_devices.txt", sizeInKB: 1 }, + { path: "protections.sqlite", sizeInKB: 100 }, + { path: "SiteSecurityServiceState.bin", sizeInKB: 10 }, + { path: ["storage", "permanent", "chrome", "123ABC.sqlite"], sizeInKB: 40 }, + { path: ["storage", "permanent", "chrome", "456DEF.sqlite"], sizeInKB: 40 }, + { + path: ["storage", "permanent", "chrome", "mockIDBDir", "890HIJ.sqlite"], + sizeInKB: 40, + }, + ]; + + await createTestFiles(tempDir, mockFiles); + + let miscDataBackupResource = new MiscDataBackupResource(); + await miscDataBackupResource.measure(tempDir); + + let measurement = Glean.browserBackup.miscDataSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.misc_data_size", + measurement, + "Glean and telemetry measurements for misc data should be equal" + ); + Assert.equal( + measurement, + EXPECTED_MISC_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for misc files" + ); + + await maybeRemovePath(tempDir); +}); + +add_task(async function test_backup() { + let sandbox = sinon.createSandbox(); + + let miscDataBackupResource = new MiscDataBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: "times.json" }, + { path: "enumerate_devices.txt" }, + { path: "SiteSecurityServiceState.bin" }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + // We have no need to test that Sqlite.sys.mjs's backup method is working - + // this is something that is tested in Sqlite's own tests. We can just make + // sure that it's being called using sinon. Unfortunately, we cannot do the + // same thing with IOUtils.copy, as its methods are not stubbable. + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await miscDataBackupResource.backup(stagingPath, sourcePath); + + await assertFilesExist(stagingPath, simpleCopyFiles); + + // Next, we'll make sure that the Sqlite connection had `backup` called on it + // with the right arguments. + Assert.ok( + fakeConnection.backup.calledOnce, + "Called backup the expected number of times for all connections" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "protections.sqlite") + ), + "Called backup on the protections.sqlite Sqlite connection" + ); + + // Bug 1890585 - we don't currently have the ability to copy the + // chrome-privileged IndexedDB databases under storage/permanent/chrome, so + // we'll just skip testing that for now. + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js b/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js new file mode 100644 index 0000000000..de97281372 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PlacesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/PlacesBackupResource.sys.mjs" +); +const { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); + +const HISTORY_ENABLED_PREF = "places.history.enabled"; +const SANITIZE_ON_SHUTDOWN_PREF = "privacy.sanitize.sanitizeOnShutdown"; + +registerCleanupFunction(() => { + /** + * Even though test_backup_no_saved_history clears user prefs too, + * clear them here as well in case that test fails and we don't + * reach the end of the test, which handles the cleanup. + */ + Services.prefs.clearUserPref(HISTORY_ENABLED_PREF); + Services.prefs.clearUserPref(SANITIZE_ON_SHUTDOWN_PREF); +}); + +/** + * Tests that we can measure Places DB related files in the profile directory. + */ +add_task(async function test_measure() { + Services.fog.testResetFOG(); + + const EXPECTED_PLACES_DB_SIZE = 5240; + const EXPECTED_FAVICONS_DB_SIZE = 5240; + + // Create resource files in temporary directory + const tempDir = PathUtils.tempDir; + let tempPlacesDBPath = PathUtils.join(tempDir, "places.sqlite"); + let tempFaviconsDBPath = PathUtils.join(tempDir, "favicons.sqlite"); + await createKilobyteSizedFile(tempPlacesDBPath, EXPECTED_PLACES_DB_SIZE); + await createKilobyteSizedFile(tempFaviconsDBPath, EXPECTED_FAVICONS_DB_SIZE); + + let placesBackupResource = new PlacesBackupResource(); + await placesBackupResource.measure(tempDir); + + let placesMeasurement = Glean.browserBackup.placesSize.testGetValue(); + let faviconsMeasurement = Glean.browserBackup.faviconsSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.places_size", + placesMeasurement, + "Glean and telemetry measurements for places.sqlite should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.favicons_size", + faviconsMeasurement, + "Glean and telemetry measurements for favicons.sqlite should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + placesMeasurement, + EXPECTED_PLACES_DB_SIZE, + "Should have collected the correct glean measurement for places.sqlite" + ); + Assert.equal( + faviconsMeasurement, + EXPECTED_FAVICONS_DB_SIZE, + "Should have collected the correct glean measurement for favicons.sqlite" + ); + + await maybeRemovePath(tempPlacesDBPath); + await maybeRemovePath(tempFaviconsDBPath); +}); + +/** + * Tests that the backup method correctly copies places.sqlite and + * favicons.sqlite from the profile directory into the staging directory. + */ +add_task(async function test_backup() { + let sandbox = sinon.createSandbox(); + + let placesBackupResource = new PlacesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-staging-test" + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.calledTwice, + "Backup should have been called twice" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "places.sqlite") + ), + "places.sqlite should have been backed up first" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(stagingPath, "favicons.sqlite") + ), + "favicons.sqlite should have been backed up second" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Tests that the backup method correctly creates a compressed bookmarks JSON file when users + * don't want history saved, even on shutdown. + */ +add_task(async function test_backup_no_saved_history() { + let sandbox = sinon.createSandbox(); + + let placesBackupResource = new PlacesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-staging-test" + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + /** + * First verify that remember history pref alone affects backup file type for places, + * despite sanitize on shutdown pref value. + */ + Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, false); + Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, false); + + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.notCalled, + "No sqlite connections should have been made with remember history disabled" + ); + await assertFilesExist(stagingPath, [{ path: "bookmarks.jsonlz4" }]); + await IOUtils.remove(PathUtils.join(stagingPath, "bookmarks.jsonlz4")); + + /** + * Now verify that the sanitize shutdown pref alone affects backup file type for places, + * even if the user is okay with remembering history while browsing. + */ + Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, true); + Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, true); + + fakeConnection.backup.resetHistory(); + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.notCalled, + "No sqlite connections should have been made with sanitize shutdown enabled" + ); + await assertFilesExist(stagingPath, [{ path: "bookmarks.jsonlz4" }]); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); + Services.prefs.clearUserPref(HISTORY_ENABLED_PREF); + Services.prefs.clearUserPref(SANITIZE_ON_SHUTDOWN_PREF); +}); + +/** + * Tests that the backup method correctly creates a compressed bookmarks JSON file when + * permanent private browsing mode is enabled. + */ +add_task(async function test_backup_private_browsing() { + let sandbox = sinon.createSandbox(); + + let placesBackupResource = new PlacesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-staging-test" + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + sandbox.stub(PrivateBrowsingUtils, "permanentPrivateBrowsing").value(true); + + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.notCalled, + "No sqlite connections should have been made with permanent private browsing enabled" + ); + await assertFilesExist(stagingPath, [{ path: "bookmarks.jsonlz4" }]); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js b/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js new file mode 100644 index 0000000000..6845431bb8 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PreferencesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/PreferencesBackupResource.sys.mjs" +); + +/** + * Test that the measure method correctly collects the disk-sizes of things that + * the PreferencesBackupResource is meant to back up. + */ +add_task(async function test_measure() { + Services.fog.testResetFOG(); + + const EXPECTED_PREFERENCES_KILOBYTES_SIZE = 415; + const tempDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-measure-test" + ); + const mockFiles = [ + { path: "prefs.js", sizeInKB: 20 }, + { path: "xulstore.json", sizeInKB: 1 }, + { path: "permissions.sqlite", sizeInKB: 100 }, + { path: "content-prefs.sqlite", sizeInKB: 260 }, + { path: "containers.json", sizeInKB: 1 }, + { path: "handlers.json", sizeInKB: 1 }, + { path: "search.json.mozlz4", sizeInKB: 1 }, + { path: "user.js", sizeInKB: 2 }, + { path: ["chrome", "userChrome.css"], sizeInKB: 5 }, + { path: ["chrome", "userContent.css"], sizeInKB: 5 }, + { path: ["chrome", "css", "mockStyles.css"], sizeInKB: 5 }, + ]; + + await createTestFiles(tempDir, mockFiles); + + let preferencesBackupResource = new PreferencesBackupResource(); + + await preferencesBackupResource.measure(tempDir); + + let measurement = Glean.browserBackup.preferencesSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.preferences_size", + measurement, + "Glean and telemetry measurements for preferences data should be equal" + ); + Assert.equal( + measurement, + EXPECTED_PREFERENCES_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for preferences files" + ); + + await maybeRemovePath(tempDir); +}); + +/** + * Test that the backup method correctly copies items from the profile directory + * into the staging directory. + */ +add_task(async function test_backup() { + let sandbox = sinon.createSandbox(); + + let preferencesBackupResource = new PreferencesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: "xulstore.json" }, + { path: "containers.json" }, + { path: "handlers.json" }, + { path: "search.json.mozlz4" }, + { path: "user.js" }, + { path: ["chrome", "userChrome.css"] }, + { path: ["chrome", "userContent.css"] }, + { path: ["chrome", "childFolder", "someOtherStylesheet.css"] }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + // We have no need to test that Sqlite.sys.mjs's backup method is working - + // this is something that is tested in Sqlite's own tests. We can just make + // sure that it's being called using sinon. Unfortunately, we cannot do the + // same thing with IOUtils.copy, as its methods are not stubbable. + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await preferencesBackupResource.backup(stagingPath, sourcePath); + + await assertFilesExist(stagingPath, simpleCopyFiles); + + // Next, we'll make sure that the Sqlite connection had `backup` called on it + // with the right arguments. + Assert.ok( + fakeConnection.backup.calledTwice, + "Called backup the expected number of times for all connections" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "permissions.sqlite") + ), + "Called backup on the permissions.sqlite Sqlite connection" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(stagingPath, "content-prefs.sqlite") + ), + "Called backup on the content-prefs.sqlite Sqlite connection" + ); + + // And we'll make sure that preferences were properly written out. + Assert.ok( + await IOUtils.exists(PathUtils.join(stagingPath, "prefs.js")), + "prefs.js should exist in the staging folder" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_createBackup.js b/browser/components/backup/tests/xpcshell/test_createBackup.js new file mode 100644 index 0000000000..fcace695ef --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_createBackup.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that calling BackupService.createBackup will call backup on each + * registered BackupResource, and that each BackupResource will have a folder + * created for them to write into. + */ +add_task(async function test_createBackup() { + let sandbox = sinon.createSandbox(); + sandbox + .stub(FakeBackupResource1.prototype, "backup") + .resolves({ fake1: "hello from 1" }); + sandbox + .stub(FakeBackupResource2.prototype, "backup") + .rejects(new Error("Some failure to backup")); + sandbox + .stub(FakeBackupResource3.prototype, "backup") + .resolves({ fake3: "hello from 3" }); + + let bs = new BackupService({ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + }); + + let fakeProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "createBackupTest" + ); + + await bs.createBackup({ profilePath: fakeProfilePath }); + + // For now, we expect a staging folder to exist under the fakeProfilePath, + // and we should find a folder for each fake BackupResource. + let stagingPath = PathUtils.join(fakeProfilePath, "backups", "staging"); + Assert.ok(await IOUtils.exists(stagingPath), "Staging folder exists"); + + for (let backupResourceClass of [ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + ]) { + let expectedResourceFolder = PathUtils.join( + stagingPath, + backupResourceClass.key + ); + Assert.ok( + await IOUtils.exists(expectedResourceFolder), + `BackupResource staging folder exists for ${backupResourceClass.key}` + ); + Assert.ok( + backupResourceClass.prototype.backup.calledOnce, + `Backup was called for ${backupResourceClass.key}` + ); + Assert.ok( + backupResourceClass.prototype.backup.calledWith( + expectedResourceFolder, + fakeProfilePath + ), + `Backup was passed the right paths for ${backupResourceClass.key}` + ); + } + + // After createBackup is more fleshed out, we're going to want to make sure + // that we're writing the manifest file and that it contains the expected + // ManifestEntry objects, and that the staging folder was successfully + // renamed with the current date. + await IOUtils.remove(fakeProfilePath, { recursive: true }); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_measurements.js b/browser/components/backup/tests/xpcshell/test_measurements.js index e5726126b2..0dece6b370 100644 --- a/browser/components/backup/tests/xpcshell/test_measurements.js +++ b/browser/components/backup/tests/xpcshell/test_measurements.js @@ -3,22 +3,59 @@ http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; -const { BackupService } = ChromeUtils.importESModule( - "resource:///modules/backup/BackupService.sys.mjs" +const { CredentialsAndSecurityBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/CredentialsAndSecurityBackupResource.sys.mjs" +); +const { AddonsBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/AddonsBackupResource.sys.mjs" +); +const { CookiesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/CookiesBackupResource.sys.mjs" +); + +const { FormHistoryBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/FormHistoryBackupResource.sys.mjs" ); -const { TelemetryTestUtils } = ChromeUtils.importESModule( - "resource://testing-common/TelemetryTestUtils.sys.mjs" +const { SessionStoreBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/SessionStoreBackupResource.sys.mjs" ); add_setup(() => { - do_get_profile(); // FOG needs to be initialized in order for data to flow. Services.fog.initializeFOG(); Services.telemetry.clearScalars(); }); /** + * Tests that calling `BackupService.takeMeasurements` will call the measure + * method of all registered BackupResource classes. + */ +add_task(async function test_takeMeasurements() { + let sandbox = sinon.createSandbox(); + sandbox.stub(FakeBackupResource1.prototype, "measure").resolves(); + sandbox + .stub(FakeBackupResource2.prototype, "measure") + .rejects(new Error("Some failure to measure")); + + let bs = new BackupService({ FakeBackupResource1, FakeBackupResource2 }); + await bs.takeMeasurements(); + + for (let backupResourceClass of [FakeBackupResource1, FakeBackupResource2]) { + Assert.ok( + backupResourceClass.prototype.measure.calledOnce, + "Measure was called" + ); + Assert.ok( + backupResourceClass.prototype.measure.calledWith(PathUtils.profileDir), + "Measure was called with the profile directory argument" + ); + } + + sandbox.restore(); +}); + +/** * Tests that we can measure the disk space available in the profile directory. */ add_task(async function test_profDDiskSpace() { @@ -38,3 +75,503 @@ add_task(async function test_profDDiskSpace() { "device" ); }); + +/** + * Tests that we can measure credentials related files in the profile directory. + */ +add_task(async function test_credentialsAndSecurityBackupResource() { + Services.fog.testResetFOG(); + + const EXPECTED_CREDENTIALS_KILOBYTES_SIZE = 413; + const EXPECTED_SECURITY_KILOBYTES_SIZE = 231; + + // Create resource files in temporary directory + const tempDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CredentialsAndSecurityBackupResource-measurement-test" + ); + + const mockFiles = [ + // Set up credentials files + { path: "key4.db", sizeInKB: 300 }, + { path: "logins.json", sizeInKB: 1 }, + { path: "logins-backup.json", sizeInKB: 1 }, + { path: "autofill-profiles.json", sizeInKB: 1 }, + { path: "credentialstate.sqlite", sizeInKB: 100 }, + { path: "signedInUser.json", sizeInKB: 5 }, + // Set up security files + { path: "cert9.db", sizeInKB: 230 }, + { path: "pkcs11.txt", sizeInKB: 1 }, + ]; + + await createTestFiles(tempDir, mockFiles); + + let credentialsAndSecurityBackupResource = + new CredentialsAndSecurityBackupResource(); + await credentialsAndSecurityBackupResource.measure(tempDir); + + let credentialsMeasurement = + Glean.browserBackup.credentialsDataSize.testGetValue(); + let securityMeasurement = Glean.browserBackup.securityDataSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Credentials measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.credentials_data_size", + credentialsMeasurement, + "Glean and telemetry measurements for credentials data should be equal" + ); + + Assert.equal( + credentialsMeasurement, + EXPECTED_CREDENTIALS_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for credentials files" + ); + + // Security measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.security_data_size", + securityMeasurement, + "Glean and telemetry measurements for security data should be equal" + ); + Assert.equal( + securityMeasurement, + EXPECTED_SECURITY_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for security files" + ); + + // Cleanup + await maybeRemovePath(tempDir); +}); + +/** + * Tests that we can measure the Cookies db in a profile directory. + */ +add_task(async function test_cookiesBackupResource() { + const EXPECTED_COOKIES_DB_SIZE = 1230; + + Services.fog.testResetFOG(); + + // Create resource files in temporary directory + let tempDir = PathUtils.tempDir; + let tempCookiesDBPath = PathUtils.join(tempDir, "cookies.sqlite"); + await createKilobyteSizedFile(tempCookiesDBPath, EXPECTED_COOKIES_DB_SIZE); + + let cookiesBackupResource = new CookiesBackupResource(); + await cookiesBackupResource.measure(tempDir); + + let cookiesMeasurement = Glean.browserBackup.cookiesSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.cookies_size", + cookiesMeasurement, + "Glean and telemetry measurements for cookies.sqlite should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + cookiesMeasurement, + EXPECTED_COOKIES_DB_SIZE, + "Should have collected the correct glean measurement for cookies.sqlite" + ); + + await maybeRemovePath(tempCookiesDBPath); +}); + +/** + * Tests that we can measure the Form History db in a profile directory. + */ +add_task(async function test_formHistoryBackupResource() { + const EXPECTED_FORM_HISTORY_DB_SIZE = 500; + + Services.fog.testResetFOG(); + + // Create resource files in temporary directory + let tempDir = PathUtils.tempDir; + let tempFormHistoryDBPath = PathUtils.join(tempDir, "formhistory.sqlite"); + await createKilobyteSizedFile( + tempFormHistoryDBPath, + EXPECTED_FORM_HISTORY_DB_SIZE + ); + + let formHistoryBackupResource = new FormHistoryBackupResource(); + await formHistoryBackupResource.measure(tempDir); + + let formHistoryMeasurement = + Glean.browserBackup.formHistorySize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.form_history_size", + formHistoryMeasurement, + "Glean and telemetry measurements for formhistory.sqlite should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + formHistoryMeasurement, + EXPECTED_FORM_HISTORY_DB_SIZE, + "Should have collected the correct glean measurement for formhistory.sqlite" + ); + + await IOUtils.remove(tempFormHistoryDBPath); +}); + +/** + * Tests that we can measure the Session Store JSON and backups directory. + */ +add_task(async function test_sessionStoreBackupResource() { + const EXPECTED_KILOBYTES_FOR_BACKUPS_DIR = 1000; + Services.fog.testResetFOG(); + + // Create the sessionstore-backups directory. + let tempDir = PathUtils.tempDir; + let sessionStoreBackupsPath = PathUtils.join( + tempDir, + "sessionstore-backups", + "restore.jsonlz4" + ); + await createKilobyteSizedFile( + sessionStoreBackupsPath, + EXPECTED_KILOBYTES_FOR_BACKUPS_DIR + ); + + let sessionStoreBackupResource = new SessionStoreBackupResource(); + await sessionStoreBackupResource.measure(tempDir); + + let sessionStoreBackupsDirectoryMeasurement = + Glean.browserBackup.sessionStoreBackupsDirectorySize.testGetValue(); + let sessionStoreMeasurement = + Glean.browserBackup.sessionStoreSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.session_store_backups_directory_size", + sessionStoreBackupsDirectoryMeasurement, + "Glean and telemetry measurements for session store backups directory should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.session_store_size", + sessionStoreMeasurement, + "Glean and telemetry measurements for session store should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + sessionStoreBackupsDirectoryMeasurement, + EXPECTED_KILOBYTES_FOR_BACKUPS_DIR, + "Should have collected the correct glean measurement for the sessionstore-backups directory" + ); + + // Session store measurement is from `getCurrentState`, so exact size is unknown. + Assert.greater( + sessionStoreMeasurement, + 0, + "Should have collected a measurement for the session store" + ); + + await IOUtils.remove(sessionStoreBackupsPath); +}); + +/** + * Tests that we can measure the size of all the addons & extensions data. + */ +add_task(async function test_AddonsBackupResource() { + Services.fog.testResetFOG(); + Services.telemetry.clearScalars(); + + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON = 250; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORE = 500; + const EXPECTED_KILOBYTES_FOR_STORAGE_SYNC = 50; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_A = 600; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_B = 400; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C = 150; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_DIRECTORY = 1000; + const EXPECTED_KILOBYTES_FOR_EXTENSION_DATA = 100; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE = 200; + + let tempDir = PathUtils.tempDir; + + // Create extensions json files (all the same size). + const extensionsFilePath = PathUtils.join(tempDir, "extensions.json"); + await createKilobyteSizedFile( + extensionsFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + const extensionSettingsFilePath = PathUtils.join( + tempDir, + "extension-settings.json" + ); + await createKilobyteSizedFile( + extensionSettingsFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + const extensionsPrefsFilePath = PathUtils.join( + tempDir, + "extension-preferences.json" + ); + await createKilobyteSizedFile( + extensionsPrefsFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + const addonStartupFilePath = PathUtils.join(tempDir, "addonStartup.json.lz4"); + await createKilobyteSizedFile( + addonStartupFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + + // Create the extension store permissions data file. + let extensionStorePermissionsDataSize = PathUtils.join( + tempDir, + "extension-store-permissions", + "data.safe.bin" + ); + await createKilobyteSizedFile( + extensionStorePermissionsDataSize, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORE + ); + + // Create the storage sync database file. + let storageSyncPath = PathUtils.join(tempDir, "storage-sync-v2.sqlite"); + await createKilobyteSizedFile( + storageSyncPath, + EXPECTED_KILOBYTES_FOR_STORAGE_SYNC + ); + + // Create the extensions directory with XPI files. + let extensionsXpiAPath = PathUtils.join( + tempDir, + "extensions", + "extension-b.xpi" + ); + let extensionsXpiBPath = PathUtils.join( + tempDir, + "extensions", + "extension-a.xpi" + ); + await createKilobyteSizedFile( + extensionsXpiAPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_A + ); + await createKilobyteSizedFile( + extensionsXpiBPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_B + ); + // Should be ignored. + let extensionsXpiStagedPath = PathUtils.join( + tempDir, + "extensions", + "staged", + "staged-test-extension.xpi" + ); + let extensionsXpiTrashPath = PathUtils.join( + tempDir, + "extensions", + "trash", + "trashed-test-extension.xpi" + ); + let extensionsXpiUnpackedPath = PathUtils.join( + tempDir, + "extensions", + "unpacked-extension.xpi", + "manifest.json" + ); + await createKilobyteSizedFile( + extensionsXpiStagedPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C + ); + await createKilobyteSizedFile( + extensionsXpiTrashPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C + ); + await createKilobyteSizedFile( + extensionsXpiUnpackedPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C + ); + + // Create the browser extension data directory. + let browserExtensionDataPath = PathUtils.join( + tempDir, + "browser-extension-data", + "test-file" + ); + await createKilobyteSizedFile( + browserExtensionDataPath, + EXPECTED_KILOBYTES_FOR_EXTENSION_DATA + ); + + // Create the extensions storage directory. + let extensionsStoragePath = PathUtils.join( + tempDir, + "storage", + "default", + "moz-extension+++test-extension-id", + "idb", + "data.sqlite" + ); + // Other storage files that should not be counted. + let otherStoragePath = PathUtils.join( + tempDir, + "storage", + "default", + "https+++accounts.firefox.com", + "ls", + "data.sqlite" + ); + + await createKilobyteSizedFile( + extensionsStoragePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE + ); + await createKilobyteSizedFile( + otherStoragePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE + ); + + // Measure all the extensions data. + let extensionsBackupResource = new AddonsBackupResource(); + await extensionsBackupResource.measure(tempDir); + + let extensionsJsonSizeMeasurement = + Glean.browserBackup.extensionsJsonSize.testGetValue(); + Assert.equal( + extensionsJsonSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON * 4, // There are 4 equally sized files. + "Should have collected the correct measurement of the total size of all extensions JSON files" + ); + + let extensionStorePermissionsDataSizeMeasurement = + Glean.browserBackup.extensionStorePermissionsDataSize.testGetValue(); + Assert.equal( + extensionStorePermissionsDataSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORE, + "Should have collected the correct measurement of the size of the extension store permissions data" + ); + + let storageSyncSizeMeasurement = + Glean.browserBackup.storageSyncSize.testGetValue(); + Assert.equal( + storageSyncSizeMeasurement, + EXPECTED_KILOBYTES_FOR_STORAGE_SYNC, + "Should have collected the correct measurement of the size of the storage sync database" + ); + + let extensionsXpiDirectorySizeMeasurement = + Glean.browserBackup.extensionsXpiDirectorySize.testGetValue(); + Assert.equal( + extensionsXpiDirectorySizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_DIRECTORY, + "Should have collected the correct measurement of the size 2 equally sized XPI files in the extensions directory" + ); + + let browserExtensionDataSizeMeasurement = + Glean.browserBackup.browserExtensionDataSize.testGetValue(); + Assert.equal( + browserExtensionDataSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSION_DATA, + "Should have collected the correct measurement of the size of the browser extension data directory" + ); + + let extensionsStorageSizeMeasurement = + Glean.browserBackup.extensionsStorageSize.testGetValue(); + Assert.equal( + extensionsStorageSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE, + "Should have collected the correct measurement of all the extensions storage" + ); + + // Compare glean vs telemetry measurements + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extensions_json_size", + extensionsJsonSizeMeasurement, + "Glean and telemetry measurements for extensions JSON should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extension_store_permissions_data_size", + extensionStorePermissionsDataSizeMeasurement, + "Glean and telemetry measurements for extension store permissions data should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.storage_sync_size", + storageSyncSizeMeasurement, + "Glean and telemetry measurements for storage sync database should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extensions_xpi_directory_size", + extensionsXpiDirectorySizeMeasurement, + "Glean and telemetry measurements for extensions directory should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.browser_extension_data_size", + browserExtensionDataSizeMeasurement, + "Glean and telemetry measurements for browser extension data should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extensions_storage_size", + extensionsStorageSizeMeasurement, + "Glean and telemetry measurements for extensions storage should be equal" + ); + + await maybeRemovePath(tempDir); +}); + +/** + * Tests that we can handle the extension store permissions data not existing. + */ +add_task( + async function test_AddonsBackupResource_no_extension_store_permissions_data() { + Services.fog.testResetFOG(); + + let tempDir = PathUtils.tempDir; + + let extensionsBackupResource = new AddonsBackupResource(); + await extensionsBackupResource.measure(tempDir); + + let extensionStorePermissionsDataSizeMeasurement = + Glean.browserBackup.extensionStorePermissionsDataSize.testGetValue(); + Assert.equal( + extensionStorePermissionsDataSizeMeasurement, + null, + "Should NOT have collected a measurement for the missing data" + ); + } +); + +/** + * Tests that we can handle a profile with no moz-extension IndexedDB databases. + */ +add_task( + async function test_AddonsBackupResource_no_extension_storage_databases() { + Services.fog.testResetFOG(); + + let tempDir = PathUtils.tempDir; + + let extensionsBackupResource = new AddonsBackupResource(); + await extensionsBackupResource.measure(tempDir); + + let extensionsStorageSizeMeasurement = + Glean.browserBackup.extensionsStorageSize.testGetValue(); + Assert.equal( + extensionsStorageSizeMeasurement, + null, + "Should NOT have collected a measurement for the missing data" + ); + } +); diff --git a/browser/components/backup/tests/xpcshell/xpcshell.toml b/browser/components/backup/tests/xpcshell/xpcshell.toml index fb6dcd6846..07e517f1f2 100644 --- a/browser/components/backup/tests/xpcshell/xpcshell.toml +++ b/browser/components/backup/tests/xpcshell/xpcshell.toml @@ -1,8 +1,20 @@ [DEFAULT] +head = "head.js" firefox-appdir = "browser" skip-if = ["os == 'android'"] +prefs = [ + "browser.backup.log=true", +] -["test_BrowserResource.js"] +["test_BackupResource.js"] support-files = ["data/test_xulstore.json"] +["test_MiscDataBackupResource.js"] + +["test_PlacesBackupResource.js"] + +["test_PreferencesBackupResource.js"] + +["test_createBackup.js"] + ["test_measurements.js"] diff --git a/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs b/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs index 34f132a539..c710f098cb 100644 --- a/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs +++ b/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs @@ -42,6 +42,13 @@ XPCOMUtils.defineLazyPreferenceGetter( "A DLP agent" ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "showBlockedResult", + "browser.contentanalysis.show_blocked_result", + true +); + /** * A class that groups browsing contexts by their top-level one. * This is necessary because if there may be a subframe that @@ -236,7 +243,7 @@ export const ContentAnalysis = { }, // nsIObserver - async observe(aSubj, aTopic, aData) { + async observe(aSubj, aTopic, _aData) { switch (aTopic) { case "quit-application-requested": { let pendingRequests = @@ -293,10 +300,10 @@ export const ContentAnalysis = { ); return; } - const operation = request.analysisType; + const analysisType = request.analysisType; // For operations that block browser interaction, show the "slow content analysis" // dialog faster - let slowTimeoutMs = this._shouldShowBlockingNotification(operation) + let slowTimeoutMs = this._shouldShowBlockingNotification(analysisType) ? this._SLOW_DLP_NOTIFICATION_BLOCKING_TIMEOUT_MS : this._SLOW_DLP_NOTIFICATION_NONBLOCKING_TIMEOUT_MS; let browsingContext = request.windowGlobalParent?.browsingContext; @@ -326,7 +333,7 @@ export const ContentAnalysis = { timer: lazy.setTimeout(() => { this.dlpBusyViewsByTopBrowsingContext.setEntry(browsingContext, { notification: this._showSlowCAMessage( - operation, + analysisType, request, resourceNameOrOperationType, browsingContext @@ -338,7 +345,7 @@ export const ContentAnalysis = { }); } break; - case "dlp-response": + case "dlp-response": { const request = aSubj.QueryInterface(Ci.nsIContentAnalysisResponse); // Cancels timer or slow message UI, // if present, and possibly presents the CA verdict. @@ -372,12 +379,14 @@ export const ContentAnalysis = { windowAndResourceNameOrOperationType.resourceNameOrOperationType, windowAndResourceNameOrOperationType.browsingContext, request.requestToken, - responseResult + responseResult, + request.cancelError ); this._showAnotherPendingDialog( windowAndResourceNameOrOperationType.browsingContext ); break; + } } }, @@ -441,7 +450,10 @@ export const ContentAnalysis = { } if (this._SHOW_NOTIFICATIONS) { - const notification = new aBrowsingContext.topChromeWindow.Notification( + let topWindow = + aBrowsingContext.topChromeWindow ?? + aBrowsingContext.embedderWindowGlobal.browsingContext.topChromeWindow; + const notification = new topWindow.Notification( this.l10n.formatValueSync("contentanalysis-notification-title"), { body: aMessage, @@ -460,10 +472,10 @@ export const ContentAnalysis = { return null; }, - _shouldShowBlockingNotification(aOperation) { + _shouldShowBlockingNotification(aAnalysisType) { return !( - aOperation == Ci.nsIContentAnalysisRequest.eFileDownloaded || - aOperation == Ci.nsIContentAnalysisRequest.ePrint + aAnalysisType == Ci.nsIContentAnalysisRequest.eFileDownloaded || + aAnalysisType == Ci.nsIContentAnalysisRequest.ePrint ); }, @@ -479,6 +491,9 @@ export const ContentAnalysis = { case Ci.nsIContentAnalysisRequest.eDroppedText: l10nId = "contentanalysis-operationtype-dropped-text"; break; + case Ci.nsIContentAnalysisRequest.eOperationPrint: + l10nId = "contentanalysis-operationtype-print"; + break; } if (!l10nId) { console.error( @@ -587,10 +602,14 @@ export const ContentAnalysis = { case Ci.nsIContentAnalysisRequest.eDroppedText: l10nId = "contentanalysis-slow-agent-dialog-body-dropped-text"; break; + case Ci.nsIContentAnalysisRequest.eOperationPrint: + l10nId = "contentanalysis-slow-agent-dialog-body-print"; + break; } if (!l10nId) { console.error( - "Unknown operationTypeForDisplay: " + aResourceNameOrOperationType + "Unknown operationTypeForDisplay: ", + aResourceNameOrOperationType ); return ""; } @@ -655,7 +674,8 @@ export const ContentAnalysis = { aResourceNameOrOperationType, aBrowsingContext, aRequestToken, - aCAResult + aCAResult, + aRequestCancelError ) { let message = null; let timeoutMs = 0; @@ -676,7 +696,7 @@ export const ContentAnalysis = { ); timeoutMs = this._RESULT_NOTIFICATION_FAST_TIMEOUT_MS; break; - case Ci.nsIContentAnalysisResponse.eWarn: + case Ci.nsIContentAnalysisResponse.eWarn: { const result = await Services.prompt.asyncConfirmEx( aBrowsingContext, Ci.nsIPromptService.MODAL_TYPE_TAB, @@ -704,7 +724,12 @@ export const ContentAnalysis = { const allow = result.get("buttonNumClicked") === 0; lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow); return null; + } case Ci.nsIContentAnalysisResponse.eBlock: + if (!lazy.showBlockedResult) { + // Don't show anything + return null; + } message = await this.l10n.formatValue("contentanalysis-block-message", { content: this._getResourceNameFromNameOrOperationType( aResourceNameOrOperationType @@ -713,13 +738,51 @@ export const ContentAnalysis = { timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; break; case Ci.nsIContentAnalysisResponse.eUnspecified: - message = await this.l10n.formatValue("contentanalysis-error-message", { - content: this._getResourceNameFromNameOrOperationType( - aResourceNameOrOperationType - ), - }); + message = await this.l10n.formatValue( + "contentanalysis-unspecified-error-message", + { + agent: lazy.agentName, + content: this._getResourceNameFromNameOrOperationType( + aResourceNameOrOperationType + ), + } + ); timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; break; + case Ci.nsIContentAnalysisResponse.eCanceled: + { + let messageId; + switch (aRequestCancelError) { + case Ci.nsIContentAnalysisResponse.eUserInitiated: + console.error( + "Got unexpected cancel response with eUserInitiated" + ); + return null; + case Ci.nsIContentAnalysisResponse.eNoAgent: + messageId = "contentanalysis-no-agent-connected-message"; + break; + case Ci.nsIContentAnalysisResponse.eInvalidAgentSignature: + messageId = "contentanalysis-invalid-agent-signature-message"; + break; + case Ci.nsIContentAnalysisResponse.eErrorOther: + messageId = "contentanalysis-unspecified-error-message"; + break; + default: + console.error( + "Unexpected CA cancelError value: " + aRequestCancelError + ); + messageId = "contentanalysis-unspecified-error-message"; + break; + } + message = await this.l10n.formatValue(messageId, { + agent: lazy.agentName, + content: this._getResourceNameFromNameOrOperationType( + aResourceNameOrOperationType + ), + }); + timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; + } + break; default: throw new Error("Unexpected CA result value: " + aCAResult); } diff --git a/browser/components/contextualidentity/test/browser/browser_eme.js b/browser/components/contextualidentity/test/browser/browser_eme.js index 5b0cd8a940..1c7432382f 100644 --- a/browser/components/contextualidentity/test/browser/browser_eme.js +++ b/browser/components/contextualidentity/test/browser/browser_eme.js @@ -121,7 +121,7 @@ add_task(async function test() { // Insert the media key. await new Promise(resolve => { - session.addEventListener("message", function (event) { + session.addEventListener("message", function () { session .update(aKeyInfo.keyObj) .then(() => { diff --git a/browser/components/contextualidentity/test/browser/browser_favicon.js b/browser/components/contextualidentity/test/browser/browser_favicon.js index 8d29aff28f..c4c615077a 100644 --- a/browser/components/contextualidentity/test/browser/browser_favicon.js +++ b/browser/components/contextualidentity/test/browser/browser_favicon.js @@ -20,7 +20,7 @@ function getIconFile() { loadUsingSystemPrincipal: true, contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, }, - function (inputStream, status) { + function (inputStream) { let size = inputStream.available(); gFaviconData = NetUtil.readInputStreamToString(inputStream, size); resolve(); diff --git a/browser/components/contextualidentity/test/browser/browser_forgetAPI_EME_forgetThisSite.js b/browser/components/contextualidentity/test/browser/browser_forgetAPI_EME_forgetThisSite.js index 24a4c51118..10b8474072 100644 --- a/browser/components/contextualidentity/test/browser/browser_forgetAPI_EME_forgetThisSite.js +++ b/browser/components/contextualidentity/test/browser/browser_forgetAPI_EME_forgetThisSite.js @@ -108,7 +108,7 @@ async function setupEMEKey(browser) { // Insert the EME key. await new Promise(resolve => { - session.addEventListener("message", function (event) { + session.addEventListener("message", function () { session .update(aKeyInfo.keyObj) .then(() => { diff --git a/browser/components/contextualidentity/test/browser/browser_forgetaboutsite.js b/browser/components/contextualidentity/test/browser/browser_forgetaboutsite.js index 79975fff8c..204c8eccbf 100644 --- a/browser/components/contextualidentity/test/browser/browser_forgetaboutsite.js +++ b/browser/components/contextualidentity/test/browser/browser_forgetaboutsite.js @@ -86,11 +86,11 @@ function OpenCacheEntry(key, where, flags, lci) { CacheListener.prototype = { QueryInterface: ChromeUtils.generateQI(["nsICacheEntryOpenCallback"]), - onCacheEntryCheck(entry) { + onCacheEntryCheck() { return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED; }, - onCacheEntryAvailable(entry, isnew, status) { + onCacheEntryAvailable() { resolve(); }, @@ -311,7 +311,7 @@ async function test_storage_cleared() { let storeRequest = store.get(1); await new Promise(done => { - storeRequest.onsuccess = event => { + storeRequest.onsuccess = () => { let res = storeRequest.result; Assert.equal( res.userContext, diff --git a/browser/components/contextualidentity/test/browser/browser_guessusercontext.js b/browser/components/contextualidentity/test/browser/browser_guessusercontext.js index 69461e67b5..90ccba9ca4 100644 --- a/browser/components/contextualidentity/test/browser/browser_guessusercontext.js +++ b/browser/components/contextualidentity/test/browser/browser_guessusercontext.js @@ -93,7 +93,7 @@ add_task(async function test() { openURIFromExternal(HOST_EXAMPLE.spec + "?new"); is( gBrowser.selectedTab.getAttribute("usercontextid"), - "", + null, "opener flow with default user context ID forced by pref" ); }); diff --git a/browser/components/contextualidentity/test/browser/browser_middleClick.js b/browser/components/contextualidentity/test/browser/browser_middleClick.js index 9b9fbcb737..d6c0feb77e 100644 --- a/browser/components/contextualidentity/test/browser/browser_middleClick.js +++ b/browser/components/contextualidentity/test/browser/browser_middleClick.js @@ -24,7 +24,7 @@ add_task(async function () { }); info("Synthesize a mouse click and wait for a new tab..."); - let newTab = await new Promise((resolve, reject) => { + let newTab = await new Promise(resolve => { gBrowser.tabContainer.addEventListener( "TabOpen", function (openEvent) { diff --git a/browser/components/contextualidentity/test/browser/browser_serviceworkers.js b/browser/components/contextualidentity/test/browser/browser_serviceworkers.js index 4f42c55d73..e40737669e 100644 --- a/browser/components/contextualidentity/test/browser/browser_serviceworkers.js +++ b/browser/components/contextualidentity/test/browser/browser_serviceworkers.js @@ -111,7 +111,7 @@ function promiseUnregister(info) { ok(aState, "ServiceWorkerRegistration exists"); resolve(); }, - unregisterFailed(aState) { + unregisterFailed() { ok(false, "unregister should succeed"); }, }, diff --git a/browser/components/contextualidentity/test/browser/browser_windowName.js b/browser/components/contextualidentity/test/browser/browser_windowName.js index 5ba2cc0e0a..256f84a8f6 100644 --- a/browser/components/contextualidentity/test/browser/browser_windowName.js +++ b/browser/components/contextualidentity/test/browser/browser_windowName.js @@ -24,7 +24,7 @@ add_task(async function test() { }); let browser1 = gBrowser.getBrowserForTab(tab1); await BrowserTestUtils.browserLoaded(browser1); - await SpecialPowers.spawn(browser1, [], function (opts) { + await SpecialPowers.spawn(browser1, [], function () { content.window.name = "tab-1"; }); @@ -34,7 +34,7 @@ add_task(async function test() { }); let browser2 = gBrowser.getBrowserForTab(tab2); await BrowserTestUtils.browserLoaded(browser2); - await SpecialPowers.spawn(browser2, [], function (opts) { + await SpecialPowers.spawn(browser2, [], function () { content.window.name = "tab-2"; }); diff --git a/browser/components/contextualidentity/test/browser/file_set_storages.html b/browser/components/contextualidentity/test/browser/file_set_storages.html index 96c46f9062..16a16d8691 100644 --- a/browser/components/contextualidentity/test/browser/file_set_storages.html +++ b/browser/components/contextualidentity/test/browser/file_set_storages.html @@ -25,7 +25,7 @@ store.createIndex("userContext", "userContext", { unique: false }); }; - request.onsuccess = event => { + request.onsuccess = () => { let db = request.result; let transaction = db.transaction(["obj"], "readwrite"); let store = transaction.objectStore("obj"); diff --git a/browser/components/controlcenter/content/protectionsPanel.inc.xhtml b/browser/components/controlcenter/content/protectionsPanel.inc.xhtml index 707105f520..29e98c2bb2 100644 --- a/browser/components/controlcenter/content/protectionsPanel.inc.xhtml +++ b/browser/components/controlcenter/content/protectionsPanel.inc.xhtml @@ -36,8 +36,8 @@ </box> <toolbarseparator></toolbarseparator> - <html:div id="messaging-system-message-container" disabled="true"> - <!-- Messaging System Messages will render in this container --> + <html:div id="info-message-container" disabled="true"> + <!-- Info message will render in this container --> </html:div> </vbox> diff --git a/browser/components/customizableui/CustomizableUI.sys.mjs b/browser/components/customizableui/CustomizableUI.sys.mjs index 5b09402dc1..9f9bbf37dc 100644 --- a/browser/components/customizableui/CustomizableUI.sys.mjs +++ b/browser/components/customizableui/CustomizableUI.sys.mjs @@ -1454,7 +1454,7 @@ var CustomizableUIInternal = { } }, - onCustomizeEnd(aWindow) { + onCustomizeEnd() { this._clearPreviousUIState(); }, @@ -6215,7 +6215,7 @@ class OverflowableToolbar { * nsIObserver implementation starts here. */ - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { // This nsIObserver method allows us to defer initialization until after // this window has finished painting and starting up. if ( diff --git a/browser/components/customizableui/CustomizableWidgets.sys.mjs b/browser/components/customizableui/CustomizableWidgets.sys.mjs index ab95e8e7db..1f37af7963 100644 --- a/browser/components/customizableui/CustomizableWidgets.sys.mjs +++ b/browser/components/customizableui/CustomizableWidgets.sys.mjs @@ -155,7 +155,7 @@ export const CustomizableWidgets = [ panelview.panelMultiView.addEventListener("PanelMultiViewHidden", this); window.addEventListener("unload", this); }, - onViewHiding(event) { + onViewHiding() { lazy.log.debug("History view is being hidden!"); }, onPanelMultiViewHidden(event) { @@ -175,7 +175,7 @@ export const CustomizableWidgets = [ } panelMultiView.removeEventListener("PanelMultiViewHidden", this); }, - onWindowUnload(event) { + onWindowUnload() { if (this._panelMenuView) { delete this._panelMenuView; } diff --git a/browser/components/customizableui/CustomizeMode.sys.mjs b/browser/components/customizableui/CustomizeMode.sys.mjs index 5f6d01d833..7b4ee373be 100644 --- a/browser/components/customizableui/CustomizeMode.sys.mjs +++ b/browser/components/customizableui/CustomizeMode.sys.mjs @@ -28,7 +28,6 @@ ChromeUtils.defineESModuleGetters(lazy, { AddonManager: "resource://gre/modules/AddonManager.sys.mjs", BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", DragPositionManager: "resource:///modules/DragPositionManager.sys.mjs", - SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () { @@ -63,14 +62,14 @@ var gTab; function closeGlobalTab() { let win = gTab.ownerGlobal; if (win.gBrowser.browsers.length == 1) { - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); } win.gBrowser.removeTab(gTab, { animate: true }); gTab = null; } var gTabsProgressListener = { - onLocationChange(aBrowser, aWebProgress, aRequest, aLocation, aFlags) { + onLocationChange(aBrowser, aWebProgress, aRequest, aLocation) { // Tear down customize mode when the customize mode tab loads some other page. // Customize mode will be re-entered if "about:blank" is loaded again, so // don't tear down in this case. @@ -221,7 +220,6 @@ CustomizeMode.prototype = { gTab = aTab; gTab.setAttribute("customizemode", "true"); - lazy.SessionStore.persistTabAttribute("customizemode"); if (gTab.linkedPanel) { gTab.linkedBrowser.stop(); @@ -663,7 +661,7 @@ CustomizeMode.prototype = { }); }, - async addToToolbar(aNode, aReason) { + async addToToolbar(aNode) { aNode = this._getCustomizableChildForNode(aNode); if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) { aNode = aNode.firstElementChild; @@ -1282,15 +1280,15 @@ CustomizeMode.prototype = { this._onUIChange(); }, - onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) { + onWidgetMoved() { this._onUIChange(); }, - onWidgetAdded(aWidgetId, aArea, aPosition) { + onWidgetAdded() { this._onUIChange(); }, - onWidgetRemoved(aWidgetId, aArea) { + onWidgetRemoved() { this._onUIChange(); }, @@ -1649,7 +1647,7 @@ CustomizeMode.prototype = { delete this.paletteDragHandler; }, - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { switch (aTopic) { case "nsPref:changed": this._updateResetButton(); @@ -2329,7 +2327,7 @@ CustomizeMode.prototype = { } }, - _setGridDragActive(aDragOverNode, aDraggedItem, aValue) { + _setGridDragActive(aDragOverNode, aDraggedItem) { let targetArea = this._getCustomizableParent(aDragOverNode); let draggedWrapper = this.$("wrapper-" + aDraggedItem.id); let originArea = this._getCustomizableParent(draggedWrapper); @@ -2428,7 +2426,7 @@ CustomizeMode.prototype = { return aElement.closest(areas.map(a => "#" + CSS.escape(a)).join(",")); }, - _getDragOverNode(aEvent, aAreaElement, aAreaType, aDraggedItemId) { + _getDragOverNode(aEvent, aAreaElement, aAreaType) { let expectedParent = CustomizableUI.getCustomizationTarget(aAreaElement) || aAreaElement; if (!expectedParent.contains(aEvent.target)) { diff --git a/browser/components/customizableui/content/panelUI.js b/browser/components/customizableui/content/panelUI.js index f99560bd42..cb32085fd7 100644 --- a/browser/components/customizableui/content/panelUI.js +++ b/browser/components/customizableui/content/panelUI.js @@ -6,7 +6,6 @@ ChromeUtils.defineESModuleGetters(this, { AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", - ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.jsm", }); /** @@ -167,9 +166,6 @@ const PanelUI = { this.menuButton.removeEventListener("mousedown", this); this.menuButton.removeEventListener("keypress", this); CustomizableUI.removeListener(this); - if (this.whatsNewPanel) { - this.whatsNewPanel.removeEventListener("ViewShowing", this); - } }, /** @@ -303,11 +299,6 @@ const PanelUI = { case "activate": this.updateNotifications(); break; - case "ViewShowing": - if (aEvent.target == this.whatsNewPanel) { - this.onWhatsNewPanelShowing(); - } - break; } }, @@ -412,7 +403,6 @@ const PanelUI = { return; } - this.ensureWhatsNewInitialized(viewNode); this.ensurePanicViewInitialized(viewNode); let container = aAnchor.closest("panelmultiview"); @@ -497,24 +487,6 @@ const PanelUI = { }, /** - * Sets up the event listener for when the What's New panel is shown. - * - * @param {panelview} panelView The What's New panelview. - */ - ensureWhatsNewInitialized(panelView) { - if (panelView.id != "PanelUI-whatsNew" || panelView._initialized) { - return; - } - - if (!this.whatsNewPanel) { - this.whatsNewPanel = panelView; - } - - panelView._initialized = true; - panelView.addEventListener("ViewShowing", this); - }, - - /** * Adds FTL before appending the panic view markup to the main DOM. * * @param {panelview} panelView The Panic View panelview. @@ -533,17 +505,6 @@ const PanelUI = { }, /** - * When the What's New panel is showing, we fetch the messages to show. - */ - onWhatsNewPanelShowing() { - ToolbarPanelHub.renderMessages( - window, - document, - "PanelUI-whatsNew-message-container" - ); - }, - - /** * NB: The enable- and disableSingleSubviewPanelAnimations methods only * affect the hiding/showing animations of single-subview panels (tempPanel * in the showSubView method). @@ -568,7 +529,7 @@ const PanelUI = { } }, - onWidgetAfterDOMChange(aNode, aNextNode, aContainer, aWasRemoval) { + onWidgetAfterDOMChange(aNode, aNextNode, aContainer) { if (aContainer == this.overflowFixedList) { this.updateOverflowStatus(); } @@ -601,7 +562,7 @@ const PanelUI = { } }, - _onHelpViewShow(aEvent) { + _onHelpViewShow() { // Call global menu setup function buildHelpMenu(); diff --git a/browser/components/customizableui/test/browser_1087303_button_fullscreen.js b/browser/components/customizableui/test/browser_1087303_button_fullscreen.js index f67e81b892..42f9b58370 100644 --- a/browser/components/customizableui/test/browser_1087303_button_fullscreen.js +++ b/browser/components/customizableui/test/browser_1087303_button_fullscreen.js @@ -44,7 +44,7 @@ function promiseFullscreenChange() { reject("Fullscreen change did not happen within " + 20000 + "ms"); }, 20000); - function onFullscreenChange(event) { + function onFullscreenChange() { clearTimeout(timeoutId); window.removeEventListener("fullscreen", onFullscreenChange, true); info("Fullscreen event received"); diff --git a/browser/components/customizableui/test/browser_1087303_button_preferences.js b/browser/components/customizableui/test/browser_1087303_button_preferences.js index 7db48341cb..86bc89f48e 100644 --- a/browser/components/customizableui/test/browser_1087303_button_preferences.js +++ b/browser/components/customizableui/test/browser_1087303_button_preferences.js @@ -47,7 +47,7 @@ function waitForPageLoad(aTab) { reject("Page didn't load within " + 20000 + "ms"); }, 20000); - async function onTabLoad(event) { + async function onTabLoad() { clearTimeout(timeoutId); aTab.linkedBrowser.removeEventListener("load", onTabLoad, true); info("Tab event received: load"); diff --git a/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js b/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js index 89b86dba20..5d44cb1664 100644 --- a/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js +++ b/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js @@ -21,7 +21,7 @@ add_task(async function test_PanelMultiView_toggle_with_other_popup() { gBrowser, url: TEST_URL, }, - async function (browser) { + async function () { // 1. Open the main menu. await gCUITestUtils.openMainMenu(); diff --git a/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js b/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js index 346608dc99..b174d2bccf 100644 --- a/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js +++ b/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js @@ -34,7 +34,7 @@ add_task(async function () { "Should not be in fullscreen sizemode before we enter fullscreen." ); - BrowserFullScreen(); + BrowserCommands.fullScreen(); await TestUtils.waitForCondition(() => isFullscreenSizeMode()); ok( fullscreenButton.checked, @@ -62,7 +62,7 @@ add_task(async function () { await endCustomizing(); - BrowserFullScreen(); + BrowserCommands.fullScreen(); fullscreenButton = document.getElementById("fullscreen-button"); await TestUtils.waitForCondition(() => !isFullscreenSizeMode()); ok( diff --git a/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js b/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js index 0cf9a93341..8f2dc87e19 100644 --- a/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js +++ b/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js @@ -124,18 +124,17 @@ add_task(async function disabled_button_in_panel() { button.remove(); }); -registerCleanupFunction(function () { +registerCleanupFunction(async function () { if (button && button.parentNode) { button.remove(); } if (menuButton && menuButton.parentNode) { menuButton.remove(); } - // Sadly this isn't task.jsm-enabled, so we can't wait for this to happen. But we should - // definitely close it here and hope it won't interfere with other tests. - // Of course, all the tests are meant to do this themselves, but if they fail... if (isOverflowOpen()) { + let panelHiddenPromise = promiseOverflowHidden(window); PanelUI.overflowPanel.hidePopup(); + await panelHiddenPromise; } CustomizableUI.reset(); }); diff --git a/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js b/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js index cc8842a3e8..42daab891f 100644 --- a/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js +++ b/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js @@ -21,7 +21,7 @@ add_task(async function () { let privateWindow = null; let observerWindowOpened = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if (aTopic == "domwindowopened") { privateWindow = aSubject; privateWindow.addEventListener( diff --git a/browser/components/customizableui/test/browser_947914_button_newWindow.js b/browser/components/customizableui/test/browser_947914_button_newWindow.js index 591d13191e..910dd2a179 100644 --- a/browser/components/customizableui/test/browser_947914_button_newWindow.js +++ b/browser/components/customizableui/test/browser_947914_button_newWindow.js @@ -21,7 +21,7 @@ add_task(async function () { let newWindow = null; let observerWindowOpened = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if (aTopic == "domwindowopened") { newWindow = aSubject; newWindow.addEventListener( diff --git a/browser/components/customizableui/test/browser_947914_button_zoomReset.js b/browser/components/customizableui/test/browser_947914_button_zoomReset.js index 7dc8299b28..c97e2f17d1 100644 --- a/browser/components/customizableui/test/browser_947914_button_zoomReset.js +++ b/browser/components/customizableui/test/browser_947914_button_zoomReset.js @@ -12,7 +12,7 @@ add_task(async function () { await BrowserTestUtils.withNewTab( { gBrowser, url: "http://example.com", waitForLoad: true }, - async function (browser) { + async function () { CustomizableUI.addWidgetToArea( "zoom-controls", CustomizableUI.AREA_FIXED_OVERFLOW_PANEL diff --git a/browser/components/customizableui/test/browser_972267_customizationchange_events.js b/browser/components/customizableui/test/browser_972267_customizationchange_events.js index 7d27b94136..fdd7236d65 100644 --- a/browser/components/customizableui/test/browser_972267_customizationchange_events.js +++ b/browser/components/customizableui/test/browser_972267_customizationchange_events.js @@ -11,7 +11,7 @@ add_task(async function () { let otherToolbox = newWindow.gNavToolbox; let handlerCalledCount = 0; - let handler = ev => { + let handler = () => { handlerCalledCount++; }; diff --git a/browser/components/customizableui/test/browser_customization_context_menus.js b/browser/components/customizableui/test/browser_customization_context_menus.js index 526b3abd1b..3f4c94fb72 100644 --- a/browser/components/customizableui/test/browser_customization_context_menus.js +++ b/browser/components/customizableui/test/browser_customization_context_menus.js @@ -171,8 +171,8 @@ add_task(async function urlbar_context() { let contextMenu = document.getElementById("toolbar-context-menu"); let shownPromise = popupShown(contextMenu); let urlBarContainer = document.getElementById("urlbar-container"); - // Need to make sure not to click within an edit field. - EventUtils.synthesizeMouse(urlBarContainer, 100, 1, { + // This clicks in the urlbar container margin, to avoid hitting the urlbar field. + EventUtils.synthesizeMouse(urlBarContainer, -2, 4, { type: "contextmenu", button: 2, }); @@ -549,7 +549,7 @@ add_task(async function custom_context_menus() { await startCustomizing(); is( widget.getAttribute("context"), - "", + null, "Should not have own context menu in the toolbar now that we're customizing." ); is( @@ -562,7 +562,7 @@ add_task(async function custom_context_menus() { simulateItemDrag(widget, panel); is( widget.getAttribute("context"), - "", + null, "Should not have own context menu when in the panel." ); is( @@ -577,7 +577,7 @@ add_task(async function custom_context_menus() { ); is( widget.getAttribute("context"), - "", + null, "Should not have own context menu when back in toolbar because we're still customizing." ); is( diff --git a/browser/components/customizableui/test/browser_editcontrols_update.js b/browser/components/customizableui/test/browser_editcontrols_update.js index 9f064e521a..1276606779 100644 --- a/browser/components/customizableui/test/browser_editcontrols_update.js +++ b/browser/components/customizableui/test/browser_editcontrols_update.js @@ -29,7 +29,7 @@ function expectCommandUpdate(count, testWindow = window) { supportsCommand(cmd) { return cmd == "cmd_delete"; }, - isCommandEnabled(cmd) { + isCommandEnabled() { if (!count) { ok(false, "unexpected update"); reject(); diff --git a/browser/components/customizableui/test/browser_open_in_lazy_tab.js b/browser/components/customizableui/test/browser_open_in_lazy_tab.js index c18de67698..696bfde69b 100644 --- a/browser/components/customizableui/test/browser_open_in_lazy_tab.js +++ b/browser/components/customizableui/test/browser_open_in_lazy_tab.js @@ -9,7 +9,7 @@ add_task(async function open_customize_mode_in_lazy_tab() { }); gCustomizeMode.setTab(tab); - is(tab.linkedPanel, "", "Tab should be lazy"); + is(tab.linkedPanel, null, "Tab should be lazy"); let title = gNavigatorBundle.getFormattedString("customizeMode.tabTitle", [ document.getElementById("bundle_brand").getString("brandShortName"), diff --git a/browser/components/customizableui/test/browser_panelUINotifications.js b/browser/components/customizableui/test/browser_panelUINotifications.js index 818fcbad39..d5f2cc0450 100644 --- a/browser/components/customizableui/test/browser_panelUINotifications.js +++ b/browser/components/customizableui/test/browser_panelUINotifications.js @@ -14,7 +14,7 @@ add_task(async function testMainActionCalled() { url: "about:blank", }; - await BrowserTestUtils.withNewTab(options, function (browser) { + await BrowserTestUtils.withNewTab(options, function () { is( PanelUI.notificationPanel.state, "closed", @@ -77,7 +77,7 @@ add_task(async function testSecondaryActionWorkflow() { url: "about:blank", }; - await BrowserTestUtils.withNewTab(options, async function (browser) { + await BrowserTestUtils.withNewTab(options, async function () { is( PanelUI.notificationPanel.state, "closed", @@ -167,7 +167,7 @@ add_task(async function testDownloadingBadge() { url: "about:blank", }; - await BrowserTestUtils.withNewTab(options, async function (browser) { + await BrowserTestUtils.withNewTab(options, async function () { let mainActionCalled = false; let mainAction = { callback: () => { @@ -225,7 +225,7 @@ add_task(async function testDownloadingBadge() { * then we display any other badges that are remaining. */ add_task(async function testInteractionWithBadges() { - await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + await BrowserTestUtils.withNewTab("about:blank", async function () { // Remove the fxa toolbar button from the navbar to ensure the notification // is displayed on the app menu button. let { CustomizableUI } = ChromeUtils.importESModule( @@ -328,7 +328,7 @@ add_task(async function testInteractionWithBadges() { * This tests that adding a badge will not dismiss any existing doorhangers. */ add_task(async function testAddingBadgeWhileDoorhangerIsShowing() { - await BrowserTestUtils.withNewTab("about:blank", function (browser) { + await BrowserTestUtils.withNewTab("about:blank", function () { is( PanelUI.notificationPanel.state, "closed", @@ -468,7 +468,7 @@ add_task(async function testMultipleBadges() { * Tests that non-badges also operate like a stack. */ add_task(async function testMultipleNonBadges() { - await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + await BrowserTestUtils.withNewTab("about:blank", async function () { is( PanelUI.notificationPanel.state, "closed", diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js index 853c39e89f..d90f928ed9 100644 --- a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js @@ -20,7 +20,7 @@ function waitForDocshellActivated() { content.document, "visibilitychange", true /* capture */, - aEvent => { + () => { return content.browsingContext.isActive; } ); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_modals.js b/browser/components/customizableui/test/browser_panelUINotifications_modals.js index 87be14fcee..a3aa6d058a 100644 --- a/browser/components/customizableui/test/browser_panelUINotifications_modals.js +++ b/browser/components/customizableui/test/browser_panelUINotifications_modals.js @@ -8,10 +8,6 @@ const { AppMenuNotifications } = ChromeUtils.importESModule( ); add_task(async function testModals() { - await SpecialPowers.pushPrefEnv({ - set: [["prompts.windowPromptSubDialog", true]], - }); - is( PanelUI.notificationPanel.state, "closed", diff --git a/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js index fd75763857..edda165692 100644 --- a/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js +++ b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js @@ -15,7 +15,7 @@ add_task(async function testDoesNotShowDoorhangerForBackgroundWindow() { url: "about:blank", }; - await BrowserTestUtils.withNewTab(options, async function (browser) { + await BrowserTestUtils.withNewTab(options, async function () { let win = await BrowserTestUtils.openNewBrowserWindow(); await SimpleTest.promiseFocus(win); let mainActionCalled = false; @@ -95,7 +95,7 @@ add_task( url: "about:blank", }; - await BrowserTestUtils.withNewTab(options, async function (browser) { + await BrowserTestUtils.withNewTab(options, async function () { let win = await BrowserTestUtils.openNewBrowserWindow(); await SimpleTest.promiseFocus(win); AppMenuNotifications.showNotification("update-manual", { callback() {} }); @@ -140,7 +140,7 @@ add_task( url: "about:blank", }; - await BrowserTestUtils.withNewTab(options, async function (browser) { + await BrowserTestUtils.withNewTab(options, async function () { let win = await BrowserTestUtils.openNewBrowserWindow(); await SimpleTest.promiseFocus(win); AppMenuNotifications.showNotification("update-manual", { callback() {} }); diff --git a/browser/components/customizableui/test/browser_switch_to_customize_mode.js b/browser/components/customizableui/test/browser_switch_to_customize_mode.js index 55e80d3517..e3988cb41e 100644 --- a/browser/components/customizableui/test/browser_switch_to_customize_mode.js +++ b/browser/components/customizableui/test/browser_switch_to_customize_mode.js @@ -18,7 +18,7 @@ add_task(async function () { await finishedCustomizing; let startedCount = 0; - let handler = e => startedCount++; + let handler = () => startedCount++; gNavToolbox.addEventListener("customizationstarting", handler); await startCustomizing(); CustomizableUI.removeWidgetFromArea("stop-reload-button"); diff --git a/browser/components/customizableui/test/browser_synced_tabs_menu.js b/browser/components/customizableui/test/browser_synced_tabs_menu.js index ff60167fea..33c8f6a845 100644 --- a/browser/components/customizableui/test/browser_synced_tabs_menu.js +++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js @@ -40,7 +40,7 @@ function updateTabsPanel() { return promiseTabsUpdated; } -// This is the mock we use for SyncedTabs.jsm - tests may override various +// This is the mock we use for SyncedTabs.sys.mjs - tests may override various // functions. let mockedInternal = { get isConfiguredToSyncTabs() { @@ -378,7 +378,7 @@ add_task(async function () { // There is a single node saying there's no tabs for the client. node = node.nextElementSibling; is(node.nodeName, "label", "node is a label"); - is(node.getAttribute("itemtype"), "", "node is neither a tab nor a client"); + is(node.getAttribute("itemtype"), null, "node is neither a tab nor a client"); node = node.nextElementSibling; is(node, null, "no more siblings"); @@ -514,7 +514,7 @@ add_task(async function () { return promise; } - showMoreButton = checkTabsPage(25, "Show More Tabs"); + showMoreButton = checkTabsPage(25, "Show more tabs"); await clickShowMoreButton(); checkTabsPage(77, null); diff --git a/browser/components/customizableui/test/head.js b/browser/components/customizableui/test/head.js index f8c0d02a12..bc1e88ed61 100644 --- a/browser/components/customizableui/test/head.js +++ b/browser/components/customizableui/test/head.js @@ -267,7 +267,7 @@ function openAndLoadWindow(aOptions, aWaitForDelayedStartup = false) { return new Promise(resolve => { let win = OpenBrowserWindow(aOptions); if (aWaitForDelayedStartup) { - Services.obs.addObserver(function onDS(aSubject, aTopic, aData) { + Services.obs.addObserver(function onDS(aSubject) { if (aSubject != win) { return; } @@ -309,7 +309,7 @@ function promisePanelElementShown(win, aPanel) { let timeoutId = win.setTimeout(() => { reject("Panel did not show within 20 seconds."); }, 20000); - function onPanelOpen(e) { + function onPanelOpen() { aPanel.removeEventListener("popupshown", onPanelOpen); win.clearTimeout(timeoutId); resolve(); @@ -328,7 +328,7 @@ function promisePanelElementHidden(win, aPanel) { let timeoutId = win.setTimeout(() => { reject("Panel did not hide within 20 seconds."); }, 20000); - function onPanelClose(e) { + function onPanelClose() { aPanel.removeEventListener("popuphidden", onPanelClose); win.clearTimeout(timeoutId); executeSoon(resolve); @@ -352,7 +352,7 @@ function subviewShown(aSubview) { let timeoutId = win.setTimeout(() => { reject("Subview (" + aSubview.id + ") did not show within 20 seconds."); }, 20000); - function onViewShown(e) { + function onViewShown() { aSubview.removeEventListener("ViewShown", onViewShown); win.clearTimeout(timeoutId); resolve(); @@ -367,7 +367,7 @@ function subviewHidden(aSubview) { let timeoutId = win.setTimeout(() => { reject("Subview (" + aSubview.id + ") did not hide within 20 seconds."); }, 20000); - function onViewHiding(e) { + function onViewHiding() { aSubview.removeEventListener("ViewHiding", onViewHiding); win.clearTimeout(timeoutId); resolve(); @@ -406,7 +406,7 @@ function promiseTabLoadEvent(aTab, aURL) { * @return {Promise} resolved when the requisite mutation shows up. */ function promiseAttributeMutation(aNode, aAttribute, aFilterFn) { - return new Promise((resolve, reject) => { + return new Promise(resolve => { info("waiting for mutation of attribute '" + aAttribute + "'."); let obs = new MutationObserver(mutations => { for (let mut of mutations) { diff --git a/browser/components/doh/DoHConfig.sys.mjs b/browser/components/doh/DoHConfig.sys.mjs index 5d35940d55..f9ac5f0f40 100644 --- a/browser/components/doh/DoHConfig.sys.mjs +++ b/browser/components/doh/DoHConfig.sys.mjs @@ -196,7 +196,7 @@ export const DoHConfigController = { return; } - Services.obs.addObserver(function obs(sub, top, data) { + Services.obs.addObserver(function obs() { Services.obs.removeObserver(obs, lazy.Region.REGION_TOPIC); updateRegionAndResolve(); }, lazy.Region.REGION_TOPIC); diff --git a/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js b/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js index cd4356ed3f..c41fa66abe 100644 --- a/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js +++ b/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js @@ -13,7 +13,7 @@ async function setPrefAndWaitForConfigFlush(pref, value) { await configFlushedPromise; } -async function clearPrefAndWaitForConfigFlush(pref, value) { +async function clearPrefAndWaitForConfigFlush(pref) { let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); Preferences.reset(pref); await configFlushedPromise; diff --git a/browser/components/enterprisepolicies/Policies.sys.mjs b/browser/components/enterprisepolicies/Policies.sys.mjs index 41fc89957c..fd15c244cc 100644 --- a/browser/components/enterprisepolicies/Policies.sys.mjs +++ b/browser/components/enterprisepolicies/Policies.sys.mjs @@ -81,23 +81,23 @@ export var Policies = { // Used for cleaning up policies. // Use the same timing that you used for setting up the policy. _cleanup: { - onBeforeAddons(manager) { + onBeforeAddons() { if (Cu.isInAutomation || isXpcshell) { console.log("_cleanup from onBeforeAddons"); clearBlockedAboutPages(); } }, - onProfileAfterChange(manager) { + onProfileAfterChange() { if (Cu.isInAutomation || isXpcshell) { console.log("_cleanup from onProfileAfterChange"); } }, - onBeforeUIStartup(manager) { + onBeforeUIStartup() { if (Cu.isInAutomation || isXpcshell) { console.log("_cleanup from onBeforeUIStartup"); } }, - onAllWindowsRestored(manager) { + onAllWindowsRestored() { if (Cu.isInAutomation || isXpcshell) { console.log("_cleanup from onAllWindowsRestored"); } @@ -112,7 +112,7 @@ export var Policies = { AllowedDomainsForApps: { onBeforeAddons(manager, param) { - Services.obs.addObserver(function (subject, topic, data) { + Services.obs.addObserver(function (subject) { let channel = subject.QueryInterface(Ci.nsIHttpChannel); if (channel.URI.host.endsWith(".google.com")) { channel.setRequestHeader("X-GoogApps-Allowed-Domains", param, true); @@ -540,6 +540,15 @@ export var Policies = { param.DenyUrlRegexList ); } + if ("AgentName" in param) { + setAndLockPref("browser.contentanalysis.agent_name", param.AgentName); + } + if ("ClientSignature" in param) { + setAndLockPref( + "browser.contentanalysis.client_signature", + param.ClientSignature + ); + } let boolPrefs = [ ["IsPerUser", "is_per_user"], ["ShowBlockedResult", "show_blocked_result"], @@ -1802,6 +1811,8 @@ export var Policies = { "places.", "pref.", "print.", + "privacy.userContext.enabled", + "privacy.userContext.ui.enabled", "signon.", "spellchecker.", "toolkit.legacyUserProfileCustomizations.stylesheets", @@ -1981,13 +1992,11 @@ export var Policies = { onBeforeAddons(manager, param) { if (param.Locked) { manager.disallowFeature("changeProxySettings"); - lazy.ProxyPolicies.configureProxySettings(param, setAndLockPref); - } else { - lazy.ProxyPolicies.configureProxySettings( - param, - PoliciesUtils.setDefaultPref - ); } + lazy.ProxyPolicies.configureProxySettings( + param, + PoliciesUtils.setDefaultPref + ); }, }, @@ -2023,6 +2032,13 @@ export var Policies = { setAndLockPref("privacy.clearOnShutdown.sessions", param); setAndLockPref("privacy.clearOnShutdown.siteSettings", param); setAndLockPref("privacy.clearOnShutdown.offlineApps", param); + setAndLockPref( + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads", + param + ); + setAndLockPref("privacy.clearOnShutdown_v2.cookiesAndStorage", param); + setAndLockPref("privacy.clearOnShutdown_v2.cache", param); + setAndLockPref("privacy.clearOnShutdown_v2.siteSettings", param); } else { let locked = true; // Needed to preserve original behavior in perpetuity. @@ -2042,12 +2058,22 @@ export var Policies = { param.Cache, locked ); + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.cache", + param.Cache, + locked + ); } else { PoliciesUtils.setDefaultPref( "privacy.clearOnShutdown.cache", false, lockDefaultPrefs ); + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.cache", + false, + lockDefaultPrefs + ); } if ("Cookies" in param) { PoliciesUtils.setDefaultPref( @@ -2055,12 +2081,26 @@ export var Policies = { param.Cookies, locked ); + + // We set cookiesAndStorage to follow lock and pref + // settings for cookies, and deprecate offlineApps + // and sessions in the new clear on shutdown dialog - Bug 1853996 + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.cookiesAndStorage", + param.Cookies, + locked + ); } else { PoliciesUtils.setDefaultPref( "privacy.clearOnShutdown.cookies", false, lockDefaultPrefs ); + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.cookiesAndStorage", + false, + lockDefaultPrefs + ); } if ("Downloads" in param) { PoliciesUtils.setDefaultPref( @@ -2094,12 +2134,26 @@ export var Policies = { param.History, locked ); + + // We set historyFormDataAndDownloads to follow lock and pref + // settings for history, and deprecate formdata and downloads + // in the new clear on shutdown dialog - Bug 1853996 + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads", + param.History, + locked + ); } else { PoliciesUtils.setDefaultPref( "privacy.clearOnShutdown.history", false, lockDefaultPrefs ); + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads", + false, + lockDefaultPrefs + ); } if ("Sessions" in param) { PoliciesUtils.setDefaultPref( @@ -2120,6 +2174,11 @@ export var Policies = { param.SiteSettings, locked ); + PoliciesUtils.setDefaultPref( + "privacy.clearOnShutdown_v2.siteSettings", + param.SiteSettings, + locked + ); } if ("OfflineApps" in param) { PoliciesUtils.setDefaultPref( @@ -2390,6 +2449,12 @@ export var Policies = { }, }, + TranslateEnabled: { + onBeforeAddons(manager, param) { + setAndLockPref("browser.translations.enable", param); + }, + }, + UserMessaging: { onBeforeAddons(manager, param) { if ("WhatsNew" in param) { @@ -2790,7 +2855,7 @@ function clearBlockedAboutPages() { gBlockedAboutPages = []; } -function blockAboutPage(manager, feature, neededOnContentProcess = false) { +function blockAboutPage(manager, feature) { addChromeURLBlocker(); gBlockedAboutPages.push(feature); @@ -2826,7 +2891,7 @@ let ChromeURLBlockPolicy = { } return Ci.nsIContentPolicy.ACCEPT; }, - shouldProcess(contentLocation, loadInfo) { + shouldProcess() { return Ci.nsIContentPolicy.ACCEPT; }, classDescription: "Policy Engine Content Policy", diff --git a/browser/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs b/browser/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs index 393b9bb85e..80968956ac 100644 --- a/browser/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs +++ b/browser/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs @@ -29,6 +29,22 @@ export var PROXY_TYPES_MAP = new Map([ ["autoConfig", Ci.nsIProtocolProxyService.PROXYCONFIG_PAC], ]); +let proxyPreferences = [ + "network.proxy.type", + "network.proxy.autoconfig_url", + "network.proxy.socks_remote_dns", + "signon.autologin.proxy", + "network.proxy.socks_version", + "network.proxy.no_proxies_on", + "network.proxy.share_proxy_settings", + "network.proxy.http", + "network.proxy.http_port", + "network.proxy.ssl", + "network.proxy.ssl_port", + "network.proxy.socks", + "network.proxy.socks_port", +]; + export var ProxyPolicies = { configureProxySettings(param, setPref) { if (param.Mode) { @@ -105,5 +121,13 @@ export var ProxyPolicies = { if (param.SOCKSProxy) { setProxyHostAndPort("socks", param.SOCKSProxy); } + + // All preferences should be locked regardless of whether or not a + // specific value was set. + if (param.Locked) { + for (let preference of proxyPreferences) { + Services.prefs.lockPref(preference); + } + } }, }; diff --git a/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs b/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs index 81f7955f27..26bae7acd9 100644 --- a/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs +++ b/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs @@ -130,10 +130,10 @@ export let WebsiteFilter = { } return Ci.nsIContentPolicy.ACCEPT; }, - shouldProcess(contentLocation, loadInfo) { + shouldProcess() { return Ci.nsIContentPolicy.ACCEPT; }, - observe(subject, topic, data) { + observe(subject) { try { let channel = subject.QueryInterface(Ci.nsIHttpChannel); if ( diff --git a/browser/components/enterprisepolicies/schemas/policies-schema.json b/browser/components/enterprisepolicies/schemas/policies-schema.json index a1ccaed74f..3c578f2c4b 100644 --- a/browser/components/enterprisepolicies/schemas/policies-schema.json +++ b/browser/components/enterprisepolicies/schemas/policies-schema.json @@ -253,6 +253,12 @@ "DenyUrlRegexList": { "type": "string" }, + "AgentName": { + "type": "string" + }, + "ClientSignature": { + "type": "string" + }, "IsPerUser": { "type": "boolean" }, @@ -662,6 +668,9 @@ "items": { "type": "string" } + }, + "temporarily_allow_weak_signatures": { + "type": "boolean" } } } @@ -691,6 +700,9 @@ "default_area": { "type": "string", "enum": ["navbar", "menupanel"] + }, + "temporarily_allow_weak_signatures": { + "type": "boolean" } } } @@ -1422,6 +1434,10 @@ "required": ["Title", "URL"] }, + "TranslateEnabled": { + "type": "boolean" + }, + "UserMessaging": { "type": "object", "properties": { diff --git a/browser/components/enterprisepolicies/tests/browser/browser.toml b/browser/components/enterprisepolicies/tests/browser/browser.toml index 25ac681e5b..0517bb6557 100644 --- a/browser/components/enterprisepolicies/tests/browser/browser.toml +++ b/browser/components/enterprisepolicies/tests/browser/browser.toml @@ -117,6 +117,8 @@ https_first_disabled = true ["browser_policy_support_menu.js"] +["browser_policy_translateenabled.js"] + ["browser_policy_usermessaging.js"] ["browser_policy_websitefilter.js"] diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_pageinfo_permissions.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_pageinfo_permissions.js index 4921464782..2fc7892c8f 100644 --- a/browser/components/enterprisepolicies/tests/browser/browser_policy_pageinfo_permissions.js +++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_pageinfo_permissions.js @@ -58,8 +58,8 @@ add_task(async function test_pageinfo_permissions() { "xr", ]; - await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function (browser) { - let pageInfo = BrowserPageInfo(TEST_ORIGIN, "permTab"); + await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function () { + let pageInfo = BrowserCommands.pageInfo(TEST_ORIGIN, "permTab"); await BrowserTestUtils.waitForEvent(pageInfo, "load"); for (let i = 0; i < permissions.length; i++) { diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_translateenabled.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_translateenabled.js new file mode 100644 index 0000000000..3658cf6388 --- /dev/null +++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_translateenabled.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function setup() { + await setupPolicyEngineWithJson({ + policies: { + TranslateEnabled: false, + }, + }); +}); + +add_task(async function test_translate_pref_disabled() { + is( + Services.prefs.getBoolPref("browser.translations.enable"), + false, + "The translations pref should be disabled when the enterprise policy is active." + ); +}); + +add_task(async function test_translate_button_disabled() { + // Since testing will apply the policy after the browser has already started, + // we will need to open a new window to actually see changes from the policy + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let appMenuButton = win.document.getElementById("PanelUI-menu-button"); + let viewShown = BrowserTestUtils.waitForEvent( + win.PanelUI.mainView, + "ViewShown" + ); + + appMenuButton.click(); + await viewShown; + + let translateSiteButton = win.document.getElementById( + "appMenu-translate-button" + ); + + is( + translateSiteButton.hidden, + true, + "The app-menu translate button should be hidden when the enterprise policy is active." + ); + + is( + translateSiteButton.disabled, + true, + "The app-menu translate button should be disabled when the enterprise policy is active." + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js b/browser/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js index 2f68436882..929f0470da 100644 --- a/browser/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js +++ b/browser/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js @@ -96,7 +96,7 @@ function waitForAboutDialog() { var domwindow = aXULWindow.docShell.domWindow; domwindow.addEventListener("load", aboutDialogOnLoad, true); }, - onCloseWindow: aXULWindow => {}, + onCloseWindow: () => {}, }; Services.wm.addListener(listener); diff --git a/browser/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js b/browser/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js index 27794aabbb..585218a016 100644 --- a/browser/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js +++ b/browser/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js @@ -73,7 +73,7 @@ async function testPageBlockedByPolicy(page, policyJSON) { async browser => { BrowserTestUtils.startLoadingURIString(browser, page); await BrowserTestUtils.browserLoaded(browser, false, page, true); - await SpecialPowers.spawn(browser, [page], async function (innerPage) { + await SpecialPowers.spawn(browser, [page], async function () { ok( content.document.documentURI.startsWith( "about:neterror?e=blockedByPolicy" diff --git a/browser/components/enterprisepolicies/tests/browser/head.js b/browser/components/enterprisepolicies/tests/browser/head.js index bb08173aa9..dfa01fad0e 100644 --- a/browser/components/enterprisepolicies/tests/browser/head.js +++ b/browser/components/enterprisepolicies/tests/browser/head.js @@ -237,7 +237,7 @@ async function testPageBlockedByPolicy(page, policyJSON) { async browser => { BrowserTestUtils.startLoadingURIString(browser, page); await BrowserTestUtils.browserLoaded(browser, false, page, true); - await SpecialPowers.spawn(browser, [page], async function (innerPage) { + await SpecialPowers.spawn(browser, [page], async function () { ok( content.document.documentURI.startsWith( "about:neterror?e=blockedByPolicy" diff --git a/browser/components/enterprisepolicies/tests/xpcshell/head.js b/browser/components/enterprisepolicies/tests/xpcshell/head.js index 8b81261538..3881760ed4 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/head.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/head.js @@ -116,7 +116,7 @@ function checkUserPref(prefName, prefValue) { ); } -function checkClearPref(prefName, prefValue) { +function checkClearPref(prefName) { equal( Services.prefs.prefHasUserValue(prefName), false, diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js b/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js index ee329a65f8..22a6269cce 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js @@ -21,7 +21,7 @@ let themeID = "policytheme@mozilla.com"; let fileURL; -add_task(async function setup() { +add_setup(async function setup() { await AddonTestUtils.promiseStartupManager(); let webExtensionFile = AddonTestUtils.createTempWebExtensionFile({ @@ -34,6 +34,10 @@ add_task(async function setup() { }, }); + server.registerFile( + "/data/amosigned-sha1only.xpi", + do_get_file("amosigned-sha1only.xpi") + ); server.registerFile("/data/policy_test.xpi", webExtensionFile); fileURL = Services.io .newFileURI(webExtensionFile) @@ -289,3 +293,112 @@ add_task(async function test_addon_normalinstalled_file() { await addon.uninstall(); }); + +add_task(async function test_allow_weak_signatures() { + // Make sure weak signatures are restricted. + const resetWeakSignaturePref = + AddonTestUtils.setWeakSignatureInstallAllowed(false); + + const id = "amosigned-xpi@tests.mozilla.org"; + const perAddonSettings = { + installation_mode: "normal_installed", + install_url: BASE_URL + "/amosigned-sha1only.xpi", + }; + + info( + "Sanity check: expect install to fail if not allowed through enterprise policy settings" + ); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onDownloadFailed"), + setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + [id]: { ...perAddonSettings }, + }, + }, + }), + ]); + let addon = await AddonManager.getAddonByID(id); + equal(addon, null, "Add-on not installed"); + + info( + "Expect install to be allowed through per-addon enterprise policy settings" + ); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onInstallEnded"), + setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + [id]: { + ...perAddonSettings, + temporarily_allow_weak_signatures: true, + }, + }, + }, + }), + ]); + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on not installed"); + await addon.uninstall(); + + info( + "Expect install to be allowed through global enterprise policy settings" + ); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onInstallEnded"), + setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + "*": { temporarily_allow_weak_signatures: true }, + [id]: { ...perAddonSettings }, + }, + }, + }), + ]); + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on installed"); + await addon.uninstall(); + + info( + "Expect install to fail if allowed globally but disallowed by per-addon settings" + ); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onDownloadFailed"), + setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + "*": { temporarily_allow_weak_signatures: true }, + [id]: { + ...perAddonSettings, + temporarily_allow_weak_signatures: false, + }, + }, + }, + }), + ]); + addon = await AddonManager.getAddonByID(id); + equal(addon, null, "Add-on not installed"); + + info( + "Expect install to be allowed through per addon setting when globally disallowed" + ); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onInstallEnded"), + setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + "*": { temporarily_allow_weak_signatures: false }, + [id]: { + ...perAddonSettings, + temporarily_allow_weak_signatures: true, + }, + }, + }, + }), + ]); + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on installed"); + await addon.uninstall(); + + resetWeakSignaturePref(); +}); diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js b/browser/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js index 5908b2d35c..bfed2491be 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js @@ -12,7 +12,7 @@ const REQ_LOC_CHANGE_EVENT = "intl:requested-locales-changed"; function promiseLocaleChanged(requestedLocale) { return new Promise(resolve => { let localeObserver = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { switch (aTopic) { case REQ_LOC_CHANGE_EVENT: let reqLocs = Services.locale.requestedLocales; @@ -26,10 +26,10 @@ function promiseLocaleChanged(requestedLocale) { }); } -function promiseLocaleNotChanged(requestedLocale) { +function promiseLocaleNotChanged() { return new Promise(resolve => { let localeObserver = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { switch (aTopic) { case REQ_LOC_CHANGE_EVENT: ok(false, "Locale should not change."); diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js b/browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js index 82caee16a7..c0952d0627 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js @@ -227,6 +227,10 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.sessions": true, "privacy.clearOnShutdown.siteSettings": true, "privacy.clearOnShutdown.offlineApps": true, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": true, + "privacy.clearOnShutdown_v2.cookiesAndStorage": true, + "privacy.clearOnShutdown_v2.cache": true, + "privacy.clearOnShutdown_v2.siteSettings": true, }, }, @@ -244,6 +248,10 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.sessions": false, "privacy.clearOnShutdown.siteSettings": false, "privacy.clearOnShutdown.offlineApps": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, + "privacy.clearOnShutdown_v2.siteSettings": false, }, }, @@ -261,6 +269,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": true, }, }, @@ -278,6 +289,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": true, + "privacy.clearOnShutdown_v2.cache": false, }, }, @@ -295,6 +309,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, }, }, @@ -312,6 +329,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": true, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, }, }, @@ -329,6 +349,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": true, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": true, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, }, }, @@ -346,6 +369,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": true, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, }, }, @@ -364,6 +390,10 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, "privacy.clearOnShutdown.siteSettings": true, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, + "privacy.clearOnShutdown_v2.siteSettings": true, }, }, @@ -382,6 +412,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, "privacy.clearOnShutdown.offlineApps": true, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": false, }, }, @@ -396,6 +429,7 @@ const POLICIES_TESTS = [ lockedPrefs: { "privacy.sanitize.sanitizeOnShutdown": true, "privacy.clearOnShutdown.cache": true, + "privacy.clearOnShutdown_v2.cache": true, }, unlockedPrefs: { "privacy.clearOnShutdown.cookies": false, @@ -403,6 +437,8 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, }, }, @@ -418,12 +454,15 @@ const POLICIES_TESTS = [ "privacy.sanitize.sanitizeOnShutdown": true, "privacy.clearOnShutdown.cache": true, "privacy.clearOnShutdown.cookies": false, + "privacy.clearOnShutdown_v2.cache": true, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, }, unlockedPrefs: { "privacy.clearOnShutdown.downloads": false, "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, }, }, @@ -442,6 +481,9 @@ const POLICIES_TESTS = [ "privacy.clearOnShutdown.formdata": false, "privacy.clearOnShutdown.history": false, "privacy.clearOnShutdown.sessions": false, + "privacy.clearOnShutdown_v2.historyFormDataAndDownloads": false, + "privacy.clearOnShutdown_v2.cookiesAndStorage": false, + "privacy.clearOnShutdown_v2.cache": true, }, }, @@ -1045,6 +1087,18 @@ const POLICIES_TESTS = [ "extensions.formautofill.creditCards.enabled": false, }, }, + + // POLICY: Proxy - locking if no values are set + { + policies: { + Proxy: { + Locked: true, + }, + }, + lockedPrefs: { + "network.proxy.type": 5, + }, + }, ]; add_task(async function test_policy_simple_prefs() { diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js b/browser/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js index 0d246c850c..73755b1dbc 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js @@ -32,7 +32,7 @@ add_task(async function test_policies_sorted() { ); checkArrayIsSorted( Object.keys(Policies), - "Policies.jsm is alphabetically sorted." + "Policies.sys.mjs is alphabetically sorted." ); }); diff --git a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml index 69dd3e5103..b21e0f9022 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml +++ b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml @@ -2,7 +2,10 @@ skip-if = ["os == 'android'"] # bug 1730213 firefox-appdir = "browser" head = "head.js" -support-files = ["policytest_v0.1.xpi"] +support-files = [ + "policytest_v0.1.xpi", + "../../../../../toolkit/mozapps/extensions/test/xpinstall/amosigned-sha1only.xpi" +] ["test_3rdparty.js"] diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js index 7b01d15101..d2f72d4f46 100644 --- a/browser/components/extensions/parent/ext-browser.js +++ b/browser/components/extensions/parent/ext-browser.js @@ -67,8 +67,12 @@ global.openOptionsPage = extension => { return Promise.reject({ message: "No browser window available" }); } - if (extension.manifest.options_ui.open_in_tab) { - window.switchToTabHavingURI(extension.manifest.options_ui.page, true, { + const { optionsPageProperties } = extension; + if (!optionsPageProperties) { + return Promise.reject({ message: "No options page" }); + } + if (optionsPageProperties.open_in_tab) { + window.switchToTabHavingURI(optionsPageProperties.page, true, { triggeringPrincipal: extension.principal, }); return Promise.resolve(); diff --git a/browser/components/extensions/parent/ext-chrome-settings-overrides.js b/browser/components/extensions/parent/ext-chrome-settings-overrides.js index 1fbb794b51..3d1b7d363e 100644 --- a/browser/components/extensions/parent/ext-chrome-settings-overrides.js +++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js @@ -56,7 +56,7 @@ ChromeUtils.defineLazyGetter(this, "homepagePopup", () => { Services.prefs.addObserver(HOMEPAGE_PREF, async function prefObserver() { Services.prefs.removeObserver(HOMEPAGE_PREF, prefObserver); let loaded = waitForTabLoaded(tab); - win.BrowserHome(); + win.BrowserCommands.home(); await loaded; // Manually trigger an event in case this is controlled again. popup.open(); diff --git a/browser/components/extensions/parent/ext-commands.js b/browser/components/extensions/parent/ext-commands.js index 328f05a802..5b2b5f11b2 100644 --- a/browser/components/extensions/parent/ext-commands.js +++ b/browser/components/extensions/parent/ext-commands.js @@ -13,8 +13,13 @@ ChromeUtils.defineESModuleGetters(this, { this.commands = class extends ExtensionAPIPersistent { PERSISTENT_EVENTS = { onCommand({ fire }) { + const { extension } = this; + const { tabManager } = extension; + let listener = (eventName, commandName) => { - fire.async(commandName); + let nativeTab = tabTracker.activeTab; + tabManager.addActiveTabPermission(nativeTab); + fire.async(commandName, tabManager.convert(nativeTab)); }; this.on("command", listener); return { diff --git a/browser/components/extensions/parent/ext-devtools-panels.js b/browser/components/extensions/parent/ext-devtools-panels.js index 6b83ea5dbb..4b88b91eab 100644 --- a/browser/components/extensions/parent/ext-devtools-panels.js +++ b/browser/components/extensions/parent/ext-devtools-panels.js @@ -104,20 +104,21 @@ class BaseDevToolsPanel { /** * Represents an addon devtools panel in the main process. - * - * @param {ExtensionChildProxyContext} context - * A devtools extension proxy context running in a main process. - * @param {object} options - * @param {string} options.id - * The id of the addon devtools panel. - * @param {string} options.icon - * The icon of the addon devtools panel. - * @param {string} options.title - * The title of the addon devtools panel. - * @param {string} options.url - * The url of the addon devtools panel, relative to the extension base URL. */ class ParentDevToolsPanel extends BaseDevToolsPanel { + /** + * @param {DevToolsExtensionPageContextParent} context + * A devtools extension proxy context running in a main process. + * @param {object} panelOptions + * @param {string} panelOptions.id + * The id of the addon devtools panel. + * @param {string} panelOptions.icon + * The icon of the addon devtools panel. + * @param {string} panelOptions.title + * The title of the addon devtools panel. + * @param {string} panelOptions.url + * The url of the addon devtools panel, relative to the extension base URL. + */ constructor(context, panelOptions) { super(context, panelOptions); @@ -339,16 +340,17 @@ class DevToolsSelectionObserver extends EventEmitter { /** * Represents an addon devtools inspector sidebar in the main process. - * - * @param {ExtensionChildProxyContext} context - * A devtools extension proxy context running in a main process. - * @param {object} options - * @param {string} options.id - * The id of the addon devtools sidebar. - * @param {string} options.title - * The title of the addon devtools sidebar. */ class ParentDevToolsInspectorSidebar extends BaseDevToolsPanel { + /** + * @param {DevToolsExtensionPageContextParent} context + * A devtools extension proxy context running in a main process. + * @param {object} panelOptions + * @param {string} panelOptions.id + * The id of the addon devtools sidebar. + * @param {string} panelOptions.title + * The title of the addon devtools sidebar. + */ constructor(context, panelOptions) { super(context, panelOptions); diff --git a/browser/components/extensions/parent/ext-tabs.js b/browser/components/extensions/parent/ext-tabs.js index 128a42439b..4b8d296d67 100644 --- a/browser/components/extensions/parent/ext-tabs.js +++ b/browser/components/extensions/parent/ext-tabs.js @@ -1026,7 +1026,13 @@ this.tabs = class extends ExtensionAPIPersistent { ? windowTracker.getTopWindow(context) : windowTracker.getWindow(windowId, context); - let tab = tabManager.wrapTab(window.gBrowser.selectedTab); + let tab = tabManager.getWrapper(window.gBrowser.selectedTab); + if ( + !extension.hasPermission("<all_urls>") && + !tab.hasActiveTabPermission + ) { + throw new ExtensionError("Missing activeTab permission"); + } await tabListener.awaitTabReady(tab.nativeTab); let zoom = window.ZoomManager.getZoomForBrowser( diff --git a/browser/components/extensions/schemas/commands.json b/browser/components/extensions/schemas/commands.json index 19e8e122f9..30942d0aab 100644 --- a/browser/components/extensions/schemas/commands.json +++ b/browser/components/extensions/schemas/commands.json @@ -104,6 +104,12 @@ { "name": "command", "type": "string" + }, + { + "name": "tab", + "$ref": "tags.Tab", + "optional": true, + "description": "Details of the $(ref:tabs.Tab) where the command was activated." } ] }, diff --git a/browser/components/extensions/schemas/tabs.json b/browser/components/extensions/schemas/tabs.json index ee7cd3dd93..55fccee0b5 100644 --- a/browser/components/extensions/schemas/tabs.json +++ b/browser/components/extensions/schemas/tabs.json @@ -1198,8 +1198,8 @@ { "name": "captureVisibleTab", "type": "function", - "description": "Captures an area of the currently active tab in the specified window. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", - "permissions": ["<all_urls>"], + "description": "Captures an area of the currently active tab in the specified window. You must have <all_urls> or activeTab permission to use this method.", + "permissions": ["<all_urls>", "activeTab"], "async": "callback", "parameters": [ { diff --git a/browser/components/extensions/test/browser/browser.toml b/browser/components/extensions/test/browser/browser.toml index 417bad7e31..570a51eeb4 100644 --- a/browser/components/extensions/test/browser/browser.toml +++ b/browser/components/extensions/test/browser/browser.toml @@ -46,10 +46,13 @@ support-files = [ "empty.xpi", "../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js", "../../../../../toolkit/components/extensions/test/mochitest/redirection.sjs", - "../../../../../toolkit/components/reader/test/readerModeNonArticle.html", - "../../../../../toolkit/components/reader/test/readerModeArticle.html", + "../../../../../toolkit/components/reader/tests/browser/readerModeNonArticle.html", + "../../../../../toolkit/components/reader/tests/browser/readerModeArticle.html", +] +skip-if = [ + "os == 'linux' && os_version == '18.04' && asan", # Bug 1721945 - Software WebRender + "os == 'linux' && os_version == '18.04' && tsan", # manifest runs too long ] -skip-if = ["os == 'linux' && os_version == '18.04' && asan"] # Bug 1721945 - Software WebRender ["browser_AMBrowserExtensionsImport.js"] @@ -700,7 +703,6 @@ tags = "fullscreen" ["browser_toolbar_prefers_color_scheme.js"] ["browser_unified_extensions.js"] -fail-if = ["a11y_checks"] # Bug 1854460 clicked browser may not be accessible ["browser_unified_extensions_accessibility.js"] diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_context.js b/browser/components/extensions/test/browser/browser_ext_browserAction_context.js index e5d315c5d2..a55952e610 100644 --- a/browser/components/extensions/test/browser/browser_ext_browserAction_context.js +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_context.js @@ -184,7 +184,11 @@ async function runTests(options) { is(getListStyleImage(button), details.icon, "icon URL is correct"); is(button.getAttribute("tooltiptext"), title, "image title is correct"); is(button.getAttribute("label"), title, "image label is correct"); - is(button.getAttribute("badge"), details.badge, "badge text is correct"); + is( + button.getAttribute("badge") || "", + details.badge, + "badge text is correct" + ); is( button.getAttribute("disabled") == "true", !details.enabled, diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js b/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js index 8e89457904..26d1536de1 100644 --- a/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js @@ -430,9 +430,6 @@ async function browseraction_contextmenu_remove_extension_helper() { }, useAddonManager: "temporary", }); - let brand = Services.strings - .createBundle("chrome://branding/locale/brand.properties") - .GetStringFromName("brandShorterName"); let { prompt } = Services; let promptService = { _response: 1, @@ -466,9 +463,6 @@ async function browseraction_contextmenu_remove_extension_helper() { await closeChromeContextMenu(menuId, removeExtension); let args = await confirmArgs; is(args[1], `Remove ${name}?`); - if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) { - is(args[2], `Remove ${name} from ${brand}?`); - } is(args[4], "Remove"); return menu; } diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js index 98e66e6c7a..289cbf8a88 100644 --- a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js @@ -50,7 +50,7 @@ async function installTestAddon(addonId, unpacked = false) { // This temporary directory is going to be removed from the // cleanup function, but also make it unique as we do for the // other temporary files (e.g. like getTemporaryFile as defined - // in XPInstall.jsm). + // in XPIInstall.sys.mjs). const random = Math.round(Math.random() * 36 ** 3).toString(36); const tmpDirName = `mochitest_unpacked_addons_${random}`; let tmpExtPath = FileUtils.getDir("TmpD", [tmpDirName]); diff --git a/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js b/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js index b67952c03c..1c9ee9a6c1 100644 --- a/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js +++ b/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js @@ -388,7 +388,7 @@ add_task(async function test_doorhanger_homepage_button() { await ext2.startup(); let popupShown = promisePopupShown(panel); - BrowserHome(); + BrowserCommands.home(); await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, () => gURLBar.value.endsWith("ext2.html") ); @@ -410,7 +410,7 @@ add_task(async function test_doorhanger_homepage_button() { popupShown = promisePopupShown(panel); await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"); let openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); - BrowserHome(); + BrowserCommands.home(); await openHomepage; await popupShown; await TestUtils.waitForCondition( @@ -432,7 +432,7 @@ add_task(async function test_doorhanger_homepage_button() { BrowserTestUtils.removeTab(gBrowser.selectedTab); openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); - BrowserHome(); + BrowserCommands.home(); await openHomepage; is(getHomePageURL(), defaultHomePage, "The homepage is set back to default"); @@ -507,7 +507,7 @@ add_task(async function test_doorhanger_new_window() { let popupShown = promisePopupShown(panel); await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"); let openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); - win.BrowserHome(); + win.BrowserCommands.home(); await openHomepage; await popupShown; @@ -547,7 +547,7 @@ async function testHomePageWindow(options = {}) { let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc); let popupShown = options.expectPanel && promisePopupShown(panel); - win.BrowserHome(); + win.BrowserCommands.home(); await Promise.all([ BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser), openHomepage, diff --git a/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js b/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js index db900f7ea4..abde8f90f7 100644 --- a/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js +++ b/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js @@ -226,7 +226,16 @@ add_task(async function test_user_defined_commands() { } function background() { - browser.commands.onCommand.addListener(commandName => { + browser.commands.onCommand.addListener(async (commandName, tab) => { + let [expectedTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + browser.test.assertEq( + tab.id, + expectedTab.id, + "Expected onCommand listener to pass the current tab" + ); browser.test.sendMessage("oncommand", commandName); }); browser.test.sendMessage("ready"); @@ -408,8 +417,9 @@ add_task(async function test_commands_event_page() { }, }, background() { - browser.commands.onCommand.addListener(name => { + browser.commands.onCommand.addListener((name, tab) => { browser.test.assertEq(name, "toggle-feature", "command received"); + browser.test.assertTrue(!!tab, "tab received"); browser.test.sendMessage("onCommand"); }); browser.test.sendMessage("ready"); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js b/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js index 548c35399f..33ad85f268 100644 --- a/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js @@ -8,11 +8,17 @@ function extensionScript() { let FRAME_URL = browser.runtime.getManifest().content_scripts[0].matches[0]; // Cannot use :8888 in the manifest because of bug 1468162. FRAME_URL = FRAME_URL.replace("mochi.test", "mochi.test:8888"); + let FRAME_ORIGIN = new URL(FRAME_URL).origin; browser.runtime.onConnect.addListener(port => { browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab"); browser.test.assertEq(port.sender.frameId, undefined, "frameId unset"); browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL"); + browser.test.assertEq( + port.sender.origin, + FRAME_ORIGIN, + "Expected sender origin" + ); port.onMessage.addListener(msg => { browser.test.assertEq("pong", msg, "Reply from content script"); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js b/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js index f751c3c202..08f19013c4 100644 --- a/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js @@ -2,12 +2,16 @@ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; +const FILE_URL = Services.io.newFileURI( + new FileUtils.File(getTestFilePath("file_dummy.html")) +).spec; + add_task(async function test_sender_url() { let extension = ExtensionTestUtils.loadExtension({ manifest: { content_scripts: [ { - matches: ["http://mochi.test/*"], + matches: ["http://mochi.test/*", "file:///*"], run_at: "document_start", js: ["script.js"], }, @@ -18,6 +22,7 @@ add_task(async function test_sender_url() { browser.runtime.onMessage.addListener((msg, sender) => { browser.test.log("Message received."); browser.test.sendMessage("sender.url", sender.url); + browser.test.sendMessage("sender.origin", sender.origin); }); }, @@ -53,6 +58,9 @@ add_task(async function test_sender_url() { let url = await extension.awaitMessage("sender.url"); is(url, image, `Correct sender.url: ${url}`); + let origin = await extension.awaitMessage("sender.origin"); + is(origin, "http://mochi.test:8888", `Correct sender.origin: ${origin}`); + let wentBack = awaitNewTab(); await browser.goBack(); await wentBack; @@ -60,6 +68,17 @@ add_task(async function test_sender_url() { await browser.goForward(); url = await extension.awaitMessage("sender.url"); is(url, image, `Correct sender.url: ${url}`); + + origin = await extension.awaitMessage("sender.origin"); + is(origin, "http://mochi.test:8888", `Correct sender.origin: ${origin}`); + }); + + await BrowserTestUtils.withNewTab(FILE_URL, async () => { + let url = await extension.awaitMessage("sender.url"); + ok(url.endsWith("/file_dummy.html"), `Correct sender.url: ${url}`); + + let origin = await extension.awaitMessage("sender.origin"); + is(origin, "null", `Correct sender.origin: ${origin}`); }); await extension.unload(); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js index 30d4d528b2..cb81d9a93b 100644 --- a/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js @@ -269,7 +269,7 @@ add_task(async function test_manifest_without_icons() { let items = menu.getElementsByAttribute("label", "first item"); is(items.length, 1, "Found first item"); // manifest.json does not declare icons, so the root menu item shouldn't have an icon either. - is(items[0].getAttribute("image"), "", "Root menu must not have an icon"); + is(items[0].getAttribute("image"), null, "Root menu must not have an icon"); await closeExtensionContextMenu(items[0]); await extension.awaitMessage("added-second-item"); @@ -281,7 +281,7 @@ add_task(async function test_manifest_without_icons() { is(items.length, 1, "Auto-generated root item exists"); is( items[0].getAttribute("image"), - "", + null, "Auto-generated menu root must not have an icon" ); @@ -464,7 +464,7 @@ add_task(async function test_child_icon_update() { contextMenuChild2 = contextMenu.getElementsByAttribute("label", "child2")[0]; is( contextMenuChild2.getAttribute("image"), - "", + null, "Second child should not have an icon" ); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js b/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js index 0004f60853..014b6dddf2 100644 --- a/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js @@ -26,7 +26,7 @@ add_task(async function test_onCreated_active() { await extension.startup(); await extension.awaitMessage("ready"); - BrowserOpenTab(); + BrowserCommands.openTab(); let tab = await extension.awaitMessage("onCreated"); is(true, tab.active, "Tab should be active"); diff --git a/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js index 988f44bd5d..a3ccc5a521 100644 --- a/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js +++ b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js @@ -45,7 +45,7 @@ async function promiseNewTab(expectUrl = AboutNewTab.newTabURL, win = window) { `Should open correct new tab url ${expectUrl}.` ); - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); const newTabCreatedPromise = newTabStartPromise; const browser = await newTabCreatedPromise; await newtabShown; diff --git a/browser/components/extensions/test/browser/browser_unified_extensions.js b/browser/components/extensions/test/browser/browser_unified_extensions.js index 7ab7753c0e..2c65f47c4e 100644 --- a/browser/components/extensions/test/browser/browser_unified_extensions.js +++ b/browser/components/extensions/test/browser/browser_unified_extensions.js @@ -1222,7 +1222,11 @@ add_task(async function test_hover_message_when_button_updates_itself() { // Move cursor to the center of the entire browser UI to avoid issues with // other focus/hover checks. We do this to avoid intermittent test failures. + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive content of the page. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); EventUtils.synthesizeMouseAtCenter(document.documentElement, {}); + AccessibilityUtils.resetEnv(); await extension.unload(); }); diff --git a/browser/components/firefoxview/HistoryController.mjs b/browser/components/firefoxview/HistoryController.mjs new file mode 100644 index 0000000000..d2bda5cec2 --- /dev/null +++ b/browser/components/firefoxview/HistoryController.mjs @@ -0,0 +1,188 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FirefoxViewPlacesQuery: + "resource:///modules/firefox-view-places-query.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +let XPCOMUtils = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +).XPCOMUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "maxRowsPref", + "browser.firefox-view.max-history-rows", + -1 +); + +const HISTORY_MAP_L10N_IDS = { + sidebar: { + "history-date-today": "sidebar-history-date-today", + "history-date-yesterday": "sidebar-history-date-yesterday", + "history-date-this-month": "sidebar-history-date-this-month", + "history-date-prev-month": "sidebar-history-date-prev-month", + }, + firefoxview: { + "history-date-today": "firefoxview-history-date-today", + "history-date-yesterday": "firefoxview-history-date-yesterday", + "history-date-this-month": "firefoxview-history-date-this-month", + "history-date-prev-month": "firefoxview-history-date-prev-month", + }, +}; + +export class HistoryController { + host; + allHistoryItems; + historyMapByDate; + historyMapBySite; + searchQuery; + searchResults; + sortOption; + + constructor(host, options) { + this.allHistoryItems = new Map(); + this.historyMapByDate = []; + this.historyMapBySite = []; + this.placesQuery = new lazy.FirefoxViewPlacesQuery(); + this.searchQuery = ""; + this.searchResults = null; + this.sortOption = "date"; + this.searchResultsLimit = options?.searchResultsLimit || 300; + this.component = HISTORY_MAP_L10N_IDS?.[options?.component] + ? options?.component + : "firefoxview"; + this.host = host; + + host.addController(this); + } + + async hostConnected() { + this.placesQuery.observeHistory(data => this.updateAllHistoryItems(data)); + await this.updateHistoryData(); + this.createHistoryMaps(); + } + + hostDisconnected() { + this.placesQuery.close(); + } + + deleteFromHistory() { + lazy.PlacesUtils.history.remove(this.host.triggerNode.url); + } + + async onSearchQuery(e) { + this.searchQuery = e.detail.query; + await this.updateSearchResults(); + this.host.requestUpdate(); + } + + async onChangeSortOption(e) { + this.sortOption = e.target.value; + await this.updateHistoryData(); + await this.updateSearchResults(); + this.host.requestUpdate(); + } + + async updateHistoryData() { + this.allHistoryItems = await this.placesQuery.getHistory({ + daysOld: 60, + limit: lazy.maxRowsPref, + sortBy: this.sortOption, + }); + } + + async updateAllHistoryItems(allHistoryItems) { + if (allHistoryItems) { + this.allHistoryItems = allHistoryItems; + } else { + await this.updateHistoryData(); + } + this.resetHistoryMaps(); + this.host.requestUpdate(); + await this.updateSearchResults(); + } + + async updateSearchResults() { + if (this.searchQuery) { + try { + this.searchResults = await this.placesQuery.searchHistory( + this.searchQuery, + this.searchResultsLimit + ); + } catch (e) { + // Connection interrupted, ignore. + } + } else { + this.searchResults = null; + } + } + + resetHistoryMaps() { + this.historyMapByDate = []; + this.historyMapBySite = []; + } + + createHistoryMaps() { + if (!this.historyMapByDate.length) { + const { + visitsFromToday, + visitsFromYesterday, + visitsByDay, + visitsByMonth, + } = this.placesQuery; + + // Add visits from today and yesterday. + if (visitsFromToday.length) { + this.historyMapByDate.push({ + l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"], + items: visitsFromToday, + }); + } + if (visitsFromYesterday.length) { + this.historyMapByDate.push({ + l10nId: + HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"], + items: visitsFromYesterday, + }); + } + + // Add visits from this month, grouped by day. + visitsByDay.forEach(visits => { + this.historyMapByDate.push({ + l10nId: + HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"], + items: visits, + }); + }); + + // Add visits from previous months, grouped by month. + visitsByMonth.forEach(visits => { + this.historyMapByDate.push({ + l10nId: + HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"], + items: visits, + }); + }); + } else if ( + this.sortOption === "site" && + !this.historyMapBySite.length && + this.component === "firefoxview" + ) { + this.historyMapBySite = Array.from( + this.allHistoryItems.entries(), + ([domain, items]) => ({ + domain, + items, + l10nId: domain ? null : "firefoxview-history-site-localhost", + }) + ).sort((a, b) => a.domain.localeCompare(b.domain)); + } + this.host.requestUpdate(); + } +} diff --git a/browser/components/firefoxview/OpenTabs.sys.mjs b/browser/components/firefoxview/OpenTabs.sys.mjs index 0771bf9e65..6d67ca44cc 100644 --- a/browser/components/firefoxview/OpenTabs.sys.mjs +++ b/browser/components/firefoxview/OpenTabs.sys.mjs @@ -33,6 +33,7 @@ const TAB_CHANGE_EVENTS = Object.freeze([ ]); const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([ "activate", + "sizemodechange", "TabAttrModified", "TabClose", "TabOpen", @@ -75,6 +76,10 @@ class OpenTabsTarget extends EventTarget { TabChange: new Set(), TabRecencyChange: new Set(), }; + #sourceEventsByType = { + TabChange: new Set(), + TabRecencyChange: new Set(), + }; #dispatchChangesTask; #started = false; #watchedWindows = new Set(); @@ -143,7 +148,7 @@ class OpenTabsTarget extends EventTarget { windowList.map(win => win.delayedStartupPromise) ).then(() => { // re-filter the list as properties might have changed in the interim - return windowList.filter(win => this.includeWindowFilter); + return windowList.filter(() => this.includeWindowFilter); }); } @@ -223,6 +228,9 @@ class OpenTabsTarget extends EventTarget { for (let changedWindows of Object.values(this.#changedWindowsByType)) { changedWindows.clear(); } + for (let sourceEvents of Object.values(this.#sourceEventsByType)) { + sourceEvents.clear(); + } this.#watchedWindows.clear(); this.#dispatchChangesTask?.disarm(); } @@ -245,9 +253,16 @@ class OpenTabsTarget extends EventTarget { tabContainer.addEventListener("TabUnpinned", this); tabContainer.addEventListener("TabSelect", this); win.addEventListener("activate", this); + win.addEventListener("sizemodechange", this); - this.#scheduleEventDispatch("TabChange", {}); - this.#scheduleEventDispatch("TabRecencyChange", {}); + this.#scheduleEventDispatch("TabChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + sourceEvent: "watchWindow", + }); + this.#scheduleEventDispatch("TabRecencyChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + sourceEvent: "watchWindow", + }); } /** @@ -270,9 +285,16 @@ class OpenTabsTarget extends EventTarget { tabContainer.removeEventListener("TabSelect", this); tabContainer.removeEventListener("TabUnpinned", this); win.removeEventListener("activate", this); + win.removeEventListener("sizemodechange", this); - this.#scheduleEventDispatch("TabChange", {}); - this.#scheduleEventDispatch("TabRecencyChange", {}); + this.#scheduleEventDispatch("TabChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + sourceEvent: "unwatchWindow", + }); + this.#scheduleEventDispatch("TabRecencyChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + sourceEvent: "unwatchWindow", + }); } } @@ -281,11 +303,12 @@ class OpenTabsTarget extends EventTarget { * Repeated calls within approx 16ms will be consolidated * into one event dispatch. */ - #scheduleEventDispatch(eventType, { sourceWindowId } = {}) { + #scheduleEventDispatch(eventType, { sourceWindowId, sourceEvent } = {}) { if (!this.haveListenersForEvent(eventType)) { return; } + this.#sourceEventsByType[eventType].add(sourceEvent); this.#changedWindowsByType[eventType].add(sourceWindowId); // Queue up an event dispatch - we use a deferred task to make this less noisy by // consolidating multiple change events into one. @@ -302,16 +325,18 @@ class OpenTabsTarget extends EventTarget { for (let [eventType, changedWindowIds] of Object.entries( this.#changedWindowsByType )) { + let sourceEvents = this.#sourceEventsByType[eventType]; if (this.haveListenersForEvent(eventType) && changedWindowIds.size) { - this.dispatchEvent( - new CustomEvent(eventType, { - detail: { - windowIds: [...changedWindowIds], - }, - }) - ); + let changeEvent = new CustomEvent(eventType, { + detail: { + windowIds: [...changedWindowIds], + sourceEvents: [...sourceEvents], + }, + }); + this.dispatchEvent(changeEvent); changedWindowIds.clear(); } + sourceEvents?.clear(); } } @@ -362,11 +387,13 @@ class OpenTabsTarget extends EventTarget { if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) { this.#scheduleEventDispatch("TabRecencyChange", { sourceWindowId: win.windowGlobalChild.innerWindowId, + sourceEvent: type, }); } if (TAB_CHANGE_EVENTS.includes(type)) { this.#scheduleEventDispatch("TabChange", { sourceWindowId: win.windowGlobalChild.innerWindowId, + sourceEvent: type, }); } } @@ -377,7 +404,7 @@ const gExclusiveWindows = new (class { constructor() { Services.obs.addObserver(this, "domwindowclosed"); } - observe(subject, topic, data) { + observe(subject) { let win = subject; let winTarget = this.perWindowInstances.get(win); if (winTarget) { diff --git a/browser/components/firefoxview/SyncedTabsController.sys.mjs b/browser/components/firefoxview/SyncedTabsController.sys.mjs new file mode 100644 index 0000000000..6ab8249bfe --- /dev/null +++ b/browser/components/firefoxview/SyncedTabsController.sys.mjs @@ -0,0 +1,333 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +import { SyncedTabsErrorHandler } from "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs"; +import { TabsSetupFlowManager } from "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs"; +import { searchTabList } from "chrome://browser/content/firefoxview/helpers.mjs"; + +const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; + +/** + * The controller for synced tabs components. + * + * @implements {ReactiveController} + */ +export class SyncedTabsController { + /** + * @type {boolean} + */ + contextMenu; + currentSetupStateIndex = -1; + currentSyncedTabs = []; + devices = []; + /** + * The current error state as determined by `SyncedTabsErrorHandler`. + * + * @type {number} + */ + errorState = null; + /** + * Component associated with this controller. + * + * @type {ReactiveControllerHost} + */ + host; + /** + * @type {Function} + */ + pairDeviceCallback; + searchQuery = ""; + /** + * @type {Function} + */ + signupCallback; + + /** + * Construct a new SyncedTabsController. + * + * @param {ReactiveControllerHost} host + * @param {object} options + * @param {boolean} [options.contextMenu] + * Whether synced tab items have a secondary context menu. + * @param {Function} [options.pairDeviceCallback] + * The function to call when the pair device window is opened. + * @param {Function} [options.signupCallback] + * The function to call when the signup window is opened. + */ + constructor(host, { contextMenu, pairDeviceCallback, signupCallback } = {}) { + this.contextMenu = contextMenu; + this.pairDeviceCallback = pairDeviceCallback; + this.signupCallback = signupCallback; + this.observe = this.observe.bind(this); + this.host = host; + this.host.addController(this); + } + + hostConnected() { + this.host.addEventListener("click", this); + } + + hostDisconnected() { + this.host.removeEventListener("click", this); + } + + addSyncObservers() { + Services.obs.addObserver(this.observe, SYNCED_TABS_CHANGED); + Services.obs.addObserver(this.observe, TOPIC_SETUPSTATE_CHANGED); + } + + removeSyncObservers() { + Services.obs.removeObserver(this.observe, SYNCED_TABS_CHANGED); + Services.obs.removeObserver(this.observe, TOPIC_SETUPSTATE_CHANGED); + } + + handleEvent(event) { + if (event.type == "click" && event.target.dataset.action) { + const { ErrorType } = SyncedTabsErrorHandler; + switch (event.target.dataset.action) { + case `${ErrorType.SYNC_ERROR}`: + case `${ErrorType.NETWORK_OFFLINE}`: + case `${ErrorType.PASSWORD_LOCKED}`: { + TabsSetupFlowManager.tryToClearError(); + break; + } + case `${ErrorType.SIGNED_OUT}`: + case "sign-in": { + TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); + this.signupCallback?.(); + break; + } + case "add-device": { + TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); + this.pairDeviceCallback?.(); + break; + } + case "sync-tabs-disabled": { + TabsSetupFlowManager.syncOpenTabs(event.target); + break; + } + case `${ErrorType.SYNC_DISCONNECTED}`: { + const win = event.target.ownerGlobal; + const { switchToTabHavingURI } = + win.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI( + "about:preferences?action=choose-what-to-sync#sync", + true, + {} + ); + break; + } + } + } + } + + async observe(_, topic, errorState) { + if (topic == TOPIC_SETUPSTATE_CHANGED) { + await this.updateStates(errorState); + } + if (topic == SYNCED_TABS_CHANGED) { + await this.getSyncedTabData(); + } + } + + async updateStates(errorState) { + let stateIndex = TabsSetupFlowManager.uiStateIndex; + errorState = errorState || SyncedTabsErrorHandler.getErrorType(); + + if (stateIndex == 4 && this.currentSetupStateIndex !== stateIndex) { + // trigger an initial request for the synced tabs list + await this.getSyncedTabData(); + } + + this.currentSetupStateIndex = stateIndex; + this.errorState = errorState; + this.host.requestUpdate(); + } + + actionMappings = { + "sign-in": { + header: "firefoxview-syncedtabs-signin-header", + description: "firefoxview-syncedtabs-signin-description", + buttonLabel: "firefoxview-syncedtabs-signin-primarybutton", + }, + "add-device": { + header: "firefoxview-syncedtabs-adddevice-header", + description: "firefoxview-syncedtabs-adddevice-description", + buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton", + descriptionLink: { + name: "url", + url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync", + }, + }, + "sync-tabs-disabled": { + header: "firefoxview-syncedtabs-synctabs-header", + description: "firefoxview-syncedtabs-synctabs-description", + buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton", + }, + loading: { + header: "firefoxview-syncedtabs-loading-header", + description: "firefoxview-syncedtabs-loading-description", + }, + }; + + #getMessageCardForState({ error = false, action, errorState }) { + errorState = errorState || this.errorState; + let header, + description, + descriptionLink, + buttonLabel, + headerIconUrl, + mainImageUrl; + let descriptionArray; + if (error) { + let link; + ({ header, description, link, buttonLabel } = + SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState)); + action = `${errorState}`; + headerIconUrl = "chrome://global/skin/icons/info-filled.svg"; + mainImageUrl = + "chrome://browser/content/firefoxview/synced-tabs-error.svg"; + descriptionArray = [description]; + if (errorState == "password-locked") { + descriptionLink = {}; + // This is ugly, but we need to special case this link so we can + // coexist with the old view. + descriptionArray.push("firefoxview-syncedtab-password-locked-link"); + descriptionLink.name = "syncedtab-password-locked-link"; + descriptionLink.url = link.href; + } + } else { + header = this.actionMappings[action].header; + description = this.actionMappings[action].description; + buttonLabel = this.actionMappings[action].buttonLabel; + descriptionLink = this.actionMappings[action].descriptionLink; + mainImageUrl = + "chrome://browser/content/firefoxview/synced-tabs-error.svg"; + descriptionArray = [description]; + } + return { + action, + buttonLabel, + descriptionArray, + descriptionLink, + error, + header, + headerIconUrl, + mainImageUrl, + }; + } + + getRenderInfo() { + let renderInfo = {}; + for (let tab of this.currentSyncedTabs) { + if (!(tab.client in renderInfo)) { + renderInfo[tab.client] = { + name: tab.device, + deviceType: tab.deviceType, + tabs: [], + }; + } + renderInfo[tab.client].tabs.push(tab); + } + + // Add devices without tabs + for (let device of this.devices) { + if (!(device.id in renderInfo)) { + renderInfo[device.id] = { + name: device.name, + deviceType: device.clientType, + tabs: [], + }; + } + } + + for (let id in renderInfo) { + renderInfo[id].tabItems = this.searchQuery + ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id].tabs)) + : this.getTabItems(renderInfo[id].tabs); + } + return renderInfo; + } + + getMessageCard() { + switch (this.currentSetupStateIndex) { + case 0 /* error-state */: + if (this.errorState) { + return this.#getMessageCardForState({ error: true }); + } + return this.#getMessageCardForState({ action: "loading" }); + case 1 /* not-signed-in */: + if (Services.prefs.prefHasUserValue("services.sync.lastversion")) { + // If this pref is set, the user has signed out of sync. + // This path is also taken if we are disconnected from sync. See bug 1784055 + return this.#getMessageCardForState({ + error: true, + errorState: "signed-out", + }); + } + return this.#getMessageCardForState({ action: "sign-in" }); + case 2 /* connect-secondary-device*/: + return this.#getMessageCardForState({ action: "add-device" }); + case 3 /* disabled-tab-sync */: + return this.#getMessageCardForState({ action: "sync-tabs-disabled" }); + case 4 /* synced-tabs-loaded*/: + // There seems to be an edge case where sync says everything worked + // fine but we have no devices. + if (!this.devices.length) { + return this.#getMessageCardForState({ action: "add-device" }); + } + } + return null; + } + + getTabItems(tabs) { + return tabs?.map(tab => ({ + icon: tab.icon, + title: tab.title, + time: tab.lastUsed * 1000, + url: tab.url, + primaryL10nId: "firefoxview-tabs-list-tab-button", + primaryL10nArgs: JSON.stringify({ targetURI: tab.url }), + secondaryL10nId: this.contextMenu + ? "fxviewtabrow-options-menu-button" + : undefined, + secondaryL10nArgs: this.contextMenu + ? JSON.stringify({ tabTitle: tab.title }) + : undefined, + })); + } + + updateTabsList(syncedTabs) { + if (!syncedTabs.length) { + this.currentSyncedTabs = syncedTabs; + } + + const tabsToRender = syncedTabs; + + // Return early if new tabs are the same as previous ones + if (lazy.ObjectUtils.deepEqual(tabsToRender, this.currentSyncedTabs)) { + return; + } + + this.currentSyncedTabs = tabsToRender; + this.host.requestUpdate(); + } + + async getSyncedTabData() { + this.devices = await lazy.SyncedTabs.getTabClients(); + let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 50, { + removeAllDupes: false, + removeDeviceDupes: true, + }); + + this.updateTabsList(tabs); + } +} diff --git a/browser/components/firefoxview/card-container.css b/browser/components/firefoxview/card-container.css index 953437bec1..0c6a81899b 100644 --- a/browser/components/firefoxview/card-container.css +++ b/browser/components/firefoxview/card-container.css @@ -14,9 +14,9 @@ } } -@media (prefers-contrast) { +@media (forced-colors) or (prefers-contrast) { .card-container { - border: 1px solid CanvasText; + border: 1px solid var(--fxview-border); } } @@ -83,7 +83,7 @@ background-color: var(--fxview-element-background-hover); } -@media (prefers-contrast) { +@media (forced-colors) { .chevron-icon { border: 1px solid ButtonText; color: ButtonText; diff --git a/browser/components/firefoxview/card-container.mjs b/browser/components/firefoxview/card-container.mjs index b58f42204a..1755d97555 100644 --- a/browser/components/firefoxview/card-container.mjs +++ b/browser/components/firefoxview/card-container.mjs @@ -118,7 +118,7 @@ class CardContainer extends MozLitElement { } updateTabLists() { - let tabLists = this.querySelectorAll("fxview-tab-list"); + let tabLists = this.querySelectorAll("fxview-tab-list, opentabs-tab-list"); if (tabLists) { tabLists.forEach(tabList => { tabList.updatesPaused = !this.visible || !this.isExpanded; diff --git a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs index 4c43eea1b6..e1c999d89c 100644 --- a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs +++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs @@ -591,12 +591,6 @@ export const TabsSetupFlowManager = new (class { ); this.didFxaTabOpen = true; openTabInWindow(window, url, true); - Services.telemetry.recordEvent( - "firefoxview_next", - "fxa_continue", - "sync", - null - ); } async openFxAPairDevice(window) { @@ -605,18 +599,9 @@ export const TabsSetupFlowManager = new (class { }); this.didFxaTabOpen = true; openTabInWindow(window, url, true); - Services.telemetry.recordEvent( - "firefoxview_next", - "fxa_mobile", - "sync", - null, - { - has_devices: this.secondaryDeviceConnected.toString(), - } - ); } - syncOpenTabs(containerElem) { + syncOpenTabs() { // Flip the pref on. // The observer should trigger re-evaluating state and advance to next step Services.prefs.setBoolPref(SYNC_TABS_PREF, true); diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css index 6811ca54c4..a91c90c39e 100644 --- a/browser/components/firefoxview/firefoxview.css +++ b/browser/components/firefoxview/firefoxview.css @@ -31,6 +31,17 @@ --newtab-background-color: #F9F9FB; --fxview-card-header-font-weight: 500; + + /* Make the attention dot color match the browser UI on Linux, and on HCM + * with a lightweight theme. */ + &[lwtheme] { + --attention-dot-color: light-dark(#2ac3a2, #54ffbd); + } + @media (-moz-platform: linux) { + &:not([lwtheme]) { + --attention-dot-color: AccentColor; + } + } } @media (prefers-color-scheme: dark) { @@ -47,7 +58,7 @@ } } -@media (prefers-contrast) { +@media (forced-colors) { :root { --fxview-element-background-hover: ButtonText; --fxview-element-background-active: ButtonText; @@ -59,6 +70,12 @@ } } +@media (prefers-contrast) { + :root { + --fxview-border: var(--border-color); + } +} + @media (max-width: 52rem) { :root { --fxview-sidebar-width: 82px; diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html index 6fa0f59a8f..5bffb5a1d8 100644 --- a/browser/components/firefoxview/firefoxview.html +++ b/browser/components/firefoxview/firefoxview.html @@ -72,6 +72,7 @@ > </moz-page-nav-button> <moz-page-nav-button + class="sync-ui-item" view="syncedtabs" data-l10n-id="firefoxview-synced-tabs-nav" iconSrc="chrome://browser/content/firefoxview/view-syncedtabs.svg" @@ -95,7 +96,10 @@ <view-recentlyclosed slot="recentlyclosed"></view-recentlyclosed> </div> <div> - <view-syncedtabs slot="syncedtabs"></view-syncedtabs> + <view-syncedtabs + class="sync-ui-item" + slot="syncedtabs" + ></view-syncedtabs> </div> </view-recentbrowsing> <view-history name="history" type="page"></view-history> @@ -104,7 +108,11 @@ name="recentlyclosed" type="page" ></view-recentlyclosed> - <view-syncedtabs name="syncedtabs" type="page"></view-syncedtabs> + <view-syncedtabs + class="sync-ui-item" + name="syncedtabs" + type="page" + ></view-syncedtabs> </named-deck> </div> </main> diff --git a/browser/components/firefoxview/firefoxview.mjs b/browser/components/firefoxview/firefoxview.mjs index 3e61482cc0..e31536bc8b 100644 --- a/browser/components/firefoxview/firefoxview.mjs +++ b/browser/components/firefoxview/firefoxview.mjs @@ -80,6 +80,16 @@ async function updateSearchKeyboardShortcut() { searchKeyboardShortcut = key.toLocaleLowerCase(); } +function updateSyncVisibility() { + const syncEnabled = Services.prefs.getBoolPref( + "identity.fxaccounts.enabled", + false + ); + for (const el of document.querySelectorAll(".sync-ui-item")) { + el.hidden = !syncEnabled; + } +} + window.addEventListener("DOMContentLoaded", async () => { recordEnteredTelemetry(); @@ -106,6 +116,7 @@ window.addEventListener("DOMContentLoaded", async () => { onViewsDeckViewChange(); await updateSearchTextboxSize(); await updateSearchKeyboardShortcut(); + updateSyncVisibility(); if (Cu.isInAutomation) { Services.obs.notifyObservers(null, "firefoxview-entered"); @@ -150,12 +161,17 @@ window.addEventListener( document.body.textContent = ""; topChromeWindow.removeEventListener("command", onCommand); Services.obs.removeObserver(onLocalesChanged, "intl:app-locales-changed"); + Services.prefs.removeObserver( + "identity.fxaccounts.enabled", + updateSyncVisibility + ); }, { once: true } ); topChromeWindow.addEventListener("command", onCommand); Services.obs.addObserver(onLocalesChanged, "intl:app-locales-changed"); +Services.prefs.addObserver("identity.fxaccounts.enabled", updateSyncVisibility); function onCommand(e) { if (document.hidden || !e.target.closest("#contentAreaContextMenu")) { diff --git a/browser/components/firefoxview/fxview-empty-state.css b/browser/components/firefoxview/fxview-empty-state.css index 80b4099e6a..8c0d08c1f8 100644 --- a/browser/components/firefoxview/fxview-empty-state.css +++ b/browser/components/firefoxview/fxview-empty-state.css @@ -93,7 +93,7 @@ img.greyscale { filter: grayscale(100%); - @media not (prefers-contrast) { + @media not (forced-colors) { opacity: 0.5; } } diff --git a/browser/components/firefoxview/fxview-tab-list.css b/browser/components/firefoxview/fxview-tab-list.css index 5a4bff023a..f0881d8ce8 100644 --- a/browser/components/firefoxview/fxview-tab-list.css +++ b/browser/components/firefoxview/fxview-tab-list.css @@ -9,35 +9,21 @@ .fxview-tab-list { display: grid; - grid-template-columns: min-content 3fr min-content 2fr 1fr 1fr min-content min-content; + grid-template-columns: min-content 3fr 2fr 1fr 1fr min-content; gap: var(--space-xsmall); - &.pinned { - display: flex; - flex-wrap: wrap; - - > virtual-list { - display: block; - } - - > fxview-tab-row { - display: block; - margin-block-end: var(--space-xsmall); - } - } - :host([compactRows]) & { - grid-template-columns: min-content 1fr min-content min-content min-content; + grid-template-columns: min-content 1fr min-content min-content; } } virtual-list { display: grid; - grid-column: span 9; + grid-column: span 7; grid-template-columns: subgrid; .top-padding, .bottom-padding { - grid-column: span 9; + grid-column: span 7; } } diff --git a/browser/components/firefoxview/fxview-tab-list.mjs b/browser/components/firefoxview/fxview-tab-list.mjs index 978ab79724..57181e3bea 100644 --- a/browser/components/firefoxview/fxview-tab-list.mjs +++ b/browser/components/firefoxview/fxview-tab-list.mjs @@ -12,6 +12,8 @@ import { } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; import { escapeRegExp } from "./helpers.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; const NOW_THRESHOLD_MS = 91000; const FXVIEW_ROW_HEIGHT_PX = 32; @@ -45,13 +47,13 @@ if (!window.IS_STORYBOOK) { * @property {string} dateTimeFormat - Expected format for date and/or time * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required * @property {number} maxTabsLength - The max number of tabs for the list - * @property {boolean} pinnedTabsGridView - Whether to show pinned tabs in a grid view * @property {Array} tabItems - Items to show in the tab list * @property {string} searchQuery - The query string to highlight, if provided. + * @property {string} searchInProgress - Whether a search has been initiated. * @property {string} secondaryActionClass - The class used to style the secondary action element * @property {string} tertiaryActionClass - The class used to style the tertiary action element */ -export default class FxviewTabList extends MozLitElement { +export class FxviewTabListBase extends MozLitElement { constructor() { super(); window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); @@ -62,10 +64,8 @@ export default class FxviewTabList extends MozLitElement { this.dateTimeFormat = "relative"; this.maxTabsLength = 25; this.tabItems = []; - this.pinnedTabs = []; - this.pinnedTabsGridView = false; - this.unpinnedTabs = []; this.compactRows = false; + this.searchInProgress = false; this.updatesPaused = true; this.#register(); } @@ -77,16 +77,18 @@ export default class FxviewTabList extends MozLitElement { dateTimeFormat: { type: String }, hasPopup: { type: String }, maxTabsLength: { type: Number }, - pinnedTabsGridView: { type: Boolean }, tabItems: { type: Array }, updatesPaused: { type: Boolean }, searchQuery: { type: String }, + searchInProgress: { type: Boolean }, secondaryActionClass: { type: String }, tertiaryActionClass: { type: String }, }; static queries = { - rowEls: { all: "fxview-tab-row" }, + rowEls: { + all: "fxview-tab-row", + }, rootVirtualListEl: "virtual-list", }; @@ -108,20 +110,7 @@ export default class FxviewTabList extends MozLitElement { } } - // Move pinned tabs to the beginning of the list - if (this.pinnedTabsGridView) { - // Can set maxTabsLength to -1 to have no max - this.unpinnedTabs = this.tabItems.filter( - tab => !tab.indicators?.includes("pinned") - ); - this.pinnedTabs = this.tabItems.filter(tab => - tab.indicators?.includes("pinned") - ); - if (this.maxTabsLength > 0) { - this.unpinnedTabs = this.unpinnedTabs.slice(0, this.maxTabsLength); - } - this.tabItems = [...this.pinnedTabs, ...this.unpinnedTabs]; - } else if (this.maxTabsLength > 0) { + if (this.maxTabsLength > 0) { this.tabItems = this.tabItems.slice(0, this.maxTabsLength); } } @@ -148,7 +137,7 @@ export default class FxviewTabList extends MozLitElement { "timeMsPref", "browser.tabs.firefox-view.updateTimeMs", NOW_THRESHOLD_MS, - (prefName, oldVal, newVal) => { + () => { this.clearIntervalTimer(); if (!this.isConnected) { return; @@ -197,93 +186,32 @@ export default class FxviewTabList extends MozLitElement { if (e.code == "ArrowUp") { // Focus either the link or button of the previous row based on this.currentActiveElementId e.preventDefault(); - if ( - (this.pinnedTabsGridView && - this.activeIndex >= this.pinnedTabs.length) || - !this.pinnedTabsGridView - ) { - this.focusPrevRow(); - } + this.focusPrevRow(); } else if (e.code == "ArrowDown") { // Focus either the link or button of the next row based on this.currentActiveElementId e.preventDefault(); - if ( - this.pinnedTabsGridView && - this.activeIndex < this.pinnedTabs.length - ) { - this.focusIndex(this.pinnedTabs.length); - } else { - this.focusNextRow(); - } + this.focusNextRow(); } else if (e.code == "ArrowRight") { // Focus either the link or the button in the current row and // set this.currentActiveElementId to that element's ID e.preventDefault(); if (document.dir == "rtl") { - this.moveFocusLeft(fxviewTabRow); + fxviewTabRow.moveFocusLeft(); } else { - this.moveFocusRight(fxviewTabRow); + fxviewTabRow.moveFocusRight(); } } else if (e.code == "ArrowLeft") { // Focus either the link or the button in the current row and // set this.currentActiveElementId to that element's ID e.preventDefault(); if (document.dir == "rtl") { - this.moveFocusRight(fxviewTabRow); + fxviewTabRow.moveFocusRight(); } else { - this.moveFocusLeft(fxviewTabRow); + fxviewTabRow.moveFocusLeft(); } } } - moveFocusRight(fxviewTabRow) { - if ( - this.pinnedTabsGridView && - fxviewTabRow.indicators?.includes("pinned") - ) { - this.focusNextRow(); - } else if ( - (fxviewTabRow.indicators?.includes("soundplaying") || - fxviewTabRow.indicators?.includes("muted")) && - this.currentActiveElementId === "fxview-tab-row-main" - ) { - this.currentActiveElementId = fxviewTabRow.focusMediaButton(); - } else if ( - this.currentActiveElementId === "fxview-tab-row-media-button" || - this.currentActiveElementId === "fxview-tab-row-main" - ) { - this.currentActiveElementId = fxviewTabRow.focusSecondaryButton(); - } else if ( - fxviewTabRow.tertiaryButtonEl && - this.currentActiveElementId === "fxview-tab-row-secondary-button" - ) { - this.currentActiveElementId = fxviewTabRow.focusTertiaryButton(); - } - } - - moveFocusLeft(fxviewTabRow) { - if ( - this.pinnedTabsGridView && - (fxviewTabRow.indicators?.includes("pinned") || - (this.currentActiveElementId === "fxview-tab-row-main" && - this.activeIndex === this.pinnedTabs.length)) - ) { - this.focusPrevRow(); - } else if ( - this.currentActiveElementId === "fxview-tab-row-tertiary-button" - ) { - this.currentActiveElementId = fxviewTabRow.focusSecondaryButton(); - } else if ( - (fxviewTabRow.indicators?.includes("soundplaying") || - fxviewTabRow.indicators?.includes("muted")) && - this.currentActiveElementId === "fxview-tab-row-secondary-button" - ) { - this.currentActiveElementId = fxviewTabRow.focusMediaButton(); - } else { - this.currentActiveElementId = fxviewTabRow.focusLink(); - } - } - focusPrevRow() { this.focusIndex(this.activeIndex - 1); } @@ -294,18 +222,12 @@ export default class FxviewTabList extends MozLitElement { async focusIndex(index) { // Focus link or button of item - if ( - ((this.pinnedTabsGridView && index > this.pinnedTabs.length) || - !this.pinnedTabsGridView) && - lazy.virtualListEnabledPref - ) { - let row = this.rootVirtualListEl.getItem(index - this.pinnedTabs.length); + if (lazy.virtualListEnabledPref) { + let row = this.rootVirtualListEl.getItem(index); if (!row) { return; } - let subList = this.rootVirtualListEl.getSubListForItem( - index - this.pinnedTabs.length - ); + let subList = this.rootVirtualListEl.getSubListForItem(index); if (!subList) { return; } @@ -347,27 +269,15 @@ export default class FxviewTabList extends MozLitElement { time = tabItem.time || tabItem.closedAt; } } + return html` <fxview-tab-row - exportparts="secondary-button" - class=${classMap({ - pinned: - this.pinnedTabsGridView && tabItem.indicators?.includes("pinned"), - })} ?active=${i == this.activeIndex} ?compact=${this.compactRows} - .hasPopup=${this.hasPopup} - .containerObj=${ifDefined(tabItem.containerObj)} .currentActiveElementId=${this.currentActiveElementId} - .dateTimeFormat=${this.dateTimeFormat} .favicon=${tabItem.icon} - .indicators=${ifDefined(tabItem.indicators)} - .pinnedTabsGridView=${ifDefined(this.pinnedTabsGridView)} .primaryL10nId=${tabItem.primaryL10nId} .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)} - role=${this.pinnedTabsGridView && tabItem.indicators?.includes("pinned") - ? "none" - : "listitem"} .secondaryL10nId=${tabItem.secondaryL10nId} .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)} .tertiaryL10nId=${ifDefined(tabItem.tertiaryL10nId)} @@ -377,41 +287,36 @@ export default class FxviewTabList extends MozLitElement { .sourceClosedId=${ifDefined(tabItem.sourceClosedId)} .sourceWindowId=${ifDefined(tabItem.sourceWindowId)} .closedId=${ifDefined(tabItem.closedId || tabItem.closedId)} - .searchQuery=${ifDefined(this.searchQuery)} + role="listitem" .tabElement=${ifDefined(tabItem.tabElement)} .time=${ifDefined(time)} - .timeMsPref=${ifDefined(this.timeMsPref)} .title=${tabItem.title} .url=${tabItem.url} + .searchQuery=${ifDefined(this.searchQuery)} + .timeMsPref=${ifDefined(this.timeMsPref)} + .hasPopup=${this.hasPopup} + .dateTimeFormat=${this.dateTimeFormat} ></fxview-tab-row> `; }; + stylesheets() { + return html`<link + rel="stylesheet" + href="chrome://browser/content/firefoxview/fxview-tab-list.css" + />`; + } + render() { - if (this.searchQuery && this.tabItems.length === 0) { - return this.#emptySearchResultsTemplate(); + if ( + this.searchQuery && + this.tabItems.length === 0 && + !this.searchInProgress + ) { + return this.emptySearchResultsTemplate(); } return html` - <link - rel="stylesheet" - href="chrome://browser/content/firefoxview/fxview-tab-list.css" - /> - ${when( - this.pinnedTabsGridView && this.pinnedTabs.length, - () => html` - <div - id="fxview-tab-list" - class="fxview-tab-list pinned" - data-l10n-id="firefoxview-pinned-tabs" - role="tablist" - @keydown=${this.handleFocusElementInRow} - > - ${this.pinnedTabs.map((tabItem, i) => - this.itemTemplate(tabItem, i) - )} - </div> - ` - )} + ${this.stylesheets()} <div id="fxview-tab-list" class="fxview-tab-list" @@ -424,28 +329,21 @@ export default class FxviewTabList extends MozLitElement { () => html` <virtual-list .activeIndex=${this.activeIndex} - .pinnedTabsIndexOffset=${this.pinnedTabsGridView - ? this.pinnedTabs.length - : 0} - .items=${this.pinnedTabsGridView - ? this.unpinnedTabs - : this.tabItems} + .items=${this.tabItems} .template=${this.itemTemplate} ></virtual-list> - ` - )} - ${when( - !lazy.virtualListEnabledPref, - () => html` - ${this.tabItems.map((tabItem, i) => this.itemTemplate(tabItem, i))} - ` + `, + () => + html`${this.tabItems.map((tabItem, i) => + this.itemTemplate(tabItem, i) + )}` )} </div> <slot name="menu"></slot> `; } - #emptySearchResultsTemplate() { + emptySearchResultsTemplate() { return html` <fxview-empty-state class="search-results" headerLabel="firefoxview-search-results-empty" @@ -455,23 +353,20 @@ export default class FxviewTabList extends MozLitElement { </fxview-empty-state>`; } } -customElements.define("fxview-tab-list", FxviewTabList); +customElements.define("fxview-tab-list", FxviewTabListBase); /** * A tab item that displays favicon, title, url, and time of last access * * @property {boolean} active - Should current item have focus on keydown * @property {boolean} compact - Whether to hide the URL and date/time for this tab. - * @property {object} containerObj - Info about an open tab's container if within one * @property {string} currentActiveElementId - ID of currently focused element within each tab item * @property {string} dateTimeFormat - Expected format for date and/or time * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required - * @property {string} indicators - An array of tab indicators if any are present * @property {number} closedId - The tab ID for when the tab item was closed. * @property {number} sourceClosedId - The closedId of the closed window its from if applicable * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable * @property {string} favicon - The favicon for the tab item. - * @property {boolean} pinnedTabsGridView - Whether the show pinned tabs in a grid view * @property {string} primaryL10nId - The l10n id used for the primary action element * @property {string} primaryL10nArgs - The l10n args used for the primary action element * @property {string} secondaryL10nId - The l10n id used for the secondary action button @@ -487,23 +382,14 @@ customElements.define("fxview-tab-list", FxviewTabList); * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time * @property {string} searchQuery - The query string to highlight, if provided. */ -export class FxviewTabRow extends MozLitElement { - constructor() { - super(); - this.active = false; - this.currentActiveElementId = "fxview-tab-row-main"; - } - +export class FxviewTabRowBase extends MozLitElement { static properties = { active: { type: Boolean }, compact: { type: Boolean }, - containerObj: { type: Object }, currentActiveElementId: { type: String }, dateTimeFormat: { type: String }, favicon: { type: String }, hasPopup: { type: String }, - indicators: { type: Array }, - pinnedTabsGridView: { type: Boolean }, primaryL10nId: { type: String }, primaryL10nArgs: { type: String }, secondaryL10nId: { type: String }, @@ -523,12 +409,16 @@ export class FxviewTabRow extends MozLitElement { searchQuery: { type: String }, }; + constructor() { + super(); + this.active = false; + this.currentActiveElementId = "fxview-tab-row-main"; + } + static queries = { mainEl: "#fxview-tab-row-main", secondaryButtonEl: "#fxview-tab-row-secondary-button:not([hidden])", tertiaryButtonEl: "#fxview-tab-row-tertiary-button", - mediaButtonEl: "#fxview-tab-row-media-button", - pinnedTabButtonEl: "button#fxview-tab-row-main", }; get currentFocusable() { @@ -539,50 +429,45 @@ export class FxviewTabRow extends MozLitElement { return focusItem; } - connectedCallback() { - super.connectedCallback(); - this.addEventListener("keydown", this.handleKeydown); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.removeEventListener("keydown", this.handleKeydown); - } - - handleKeydown(e) { - if ( - this.active && - this.pinnedTabsGridView && - this.indicators?.includes("pinned") && - e.key === "m" && - e.ctrlKey - ) { - this.muteOrUnmuteTab(); - } - } - focus() { this.currentFocusable.focus(); } focusSecondaryButton() { + let tabList = this.getRootNode().host; this.secondaryButtonEl.focus(); - return this.secondaryButtonEl.id; + tabList.currentActiveElementId = this.secondaryButtonEl.id; } focusTertiaryButton() { + let tabList = this.getRootNode().host; this.tertiaryButtonEl.focus(); - return this.tertiaryButtonEl.id; - } - - focusMediaButton() { - this.mediaButtonEl.focus(); - return this.mediaButtonEl.id; + tabList.currentActiveElementId = this.tertiaryButtonEl.id; } focusLink() { + let tabList = this.getRootNode().host; this.mainEl.focus(); - return this.mainEl.id; + tabList.currentActiveElementId = this.mainEl.id; + } + + moveFocusRight() { + if (this.currentActiveElementId === "fxview-tab-row-main") { + this.focusSecondaryButton(); + } else if ( + this.tertiaryButtonEl && + this.currentActiveElementId === "fxview-tab-row-secondary-button" + ) { + this.focusTertiaryButton(); + } + } + + moveFocusLeft() { + if (this.currentActiveElementId === "fxview-tab-row-tertiary-button") { + this.focusSecondaryButton(); + } else { + this.focusLink(); + } } dateFluentArgs(timestamp, dateTimeFormat) { @@ -652,16 +537,6 @@ export class FxviewTabRow extends MozLitElement { return icon; } - getContainerClasses() { - let containerClasses = ["fxview-tab-row-container-indicator", "icon"]; - if (this.containerObj) { - let { icon, color } = this.containerObj; - containerClasses.push(`identity-icon-${icon}`); - containerClasses.push(`identity-color-${color}`); - } - return containerClasses; - } - primaryActionHandler(event) { if ( (event.type == "click" && !event.altKey) || @@ -683,9 +558,6 @@ export class FxviewTabRow extends MozLitElement { secondaryActionHandler(event) { if ( - (this.pinnedTabsGridView && - this.indicators?.includes("pinned") && - event.type == "contextmenu") || (event.type == "click" && event.detail && !event.altKey) || // detail=0 is from keyboard (event.type == "click" && !event.detail) @@ -718,92 +590,80 @@ export class FxviewTabRow extends MozLitElement { } } - muteOrUnmuteTab(e) { - e?.preventDefault(); - // If the tab has no sound playing, the mute/unmute button will be removed when toggled. - // We should move the focus to the right in that case. This does not apply to pinned tabs - // on the Open Tabs page. - let shouldMoveFocus = - (!this.pinnedTabsGridView || - (!this.indicators.includes("pinned") && this.pinnedTabsGridView)) && - this.mediaButtonEl && - !this.indicators.includes("soundplaying") && - this.currentActiveElementId === "fxview-tab-row-media-button"; - - // detail=0 is from keyboard - if (e?.type == "click" && !e?.detail && shouldMoveFocus) { - let tabList = this.getRootNode().host; - if (document.dir == "rtl") { - tabList.moveFocusLeft(this); - } else { - tabList.moveFocusRight(this); - } + /** + * Find all matches of query within the given string, and compute the result + * to be rendered. + * + * @param {string} query + * @param {string} string + */ + highlightSearchMatches(query, string) { + const fragments = []; + const regex = RegExp(escapeRegExp(query), "dgi"); + let prevIndexEnd = 0; + let result; + while ((result = regex.exec(string)) !== null) { + const [indexStart, indexEnd] = result.indices[0]; + fragments.push(string.substring(prevIndexEnd, indexStart)); + fragments.push( + html`<strong>${string.substring(indexStart, indexEnd)}</strong>` + ); + prevIndexEnd = regex.lastIndex; } - this.tabElement.toggleMuteAudio(); + fragments.push(string.substring(prevIndexEnd)); + return fragments; + } + + stylesheets() { + return html`<link + rel="stylesheet" + href="chrome://browser/content/firefoxview/fxview-tab-row.css" + />`; } - #faviconTemplate() { + faviconTemplate() { return html`<span - class="${classMap({ - "fxview-tab-row-favicon-wrapper": true, - pinned: this.indicators?.includes("pinned"), - pinnedOnNewTab: this.indicators?.includes("pinnedOnNewTab"), - attention: this.indicators?.includes("attention"), - bookmark: this.indicators?.includes("bookmark"), - })}" + class="fxview-tab-row-favicon icon" + id="fxview-tab-row-favicon" + style=${styleMap({ + backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`, + })} + ></span>`; + } + + titleTemplate() { + const title = this.title; + return html`<span + class="fxview-tab-row-title text-truncated-ellipsis" + id="fxview-tab-row-title" + dir="auto" > - <span - class="fxview-tab-row-favicon icon" - id="fxview-tab-row-favicon" - style=${styleMap({ - backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`, - })} - ></span> ${when( - this.pinnedTabsGridView && - this.indicators?.includes("pinned") && - (this.indicators?.includes("muted") || - this.indicators?.includes("soundplaying")), - () => html` - <button - class="fxview-tab-row-pinned-media-button ghost-button icon-button" - id="fxview-tab-row-media-button" - tabindex="-1" - data-l10n-id=${this.indicators?.includes("muted") - ? "fxviewtabrow-unmute-tab-button-no-context" - : "fxviewtabrow-mute-tab-button-no-context"} - muted=${this.indicators?.includes("muted")} - soundplaying=${this.indicators?.includes("soundplaying") && - !this.indicators?.includes("muted")} - @click=${this.muteOrUnmuteTab} - ></button> - ` + this.searchQuery, + () => this.highlightSearchMatches(this.searchQuery, title), + () => title )} </span>`; } - #pinnedTabItemTemplate() { - return html` <button - class="fxview-tab-row-main ghost-button semi-transparent" - id="fxview-tab-row-main" - aria-haspopup=${ifDefined(this.hasPopup)} - data-l10n-id=${ifDefined(this.primaryL10nId)} - data-l10n-args=${ifDefined(this.primaryL10nArgs)} - tabindex=${this.active && - this.currentActiveElementId === "fxview-tab-row-main" - ? "0" - : "-1"} - role="tab" - @click=${this.primaryActionHandler} - @keydown=${this.primaryActionHandler} - @contextmenu=${this.secondaryActionHandler} + urlTemplate() { + return html`<span + class="fxview-tab-row-url text-truncated-ellipsis" + id="fxview-tab-row-url" > - ${this.#faviconTemplate()} - </button>`; + ${when( + this.searchQuery, + () => + this.highlightSearchMatches( + this.searchQuery, + this.formatURIForDisplay(this.url) + ), + () => this.formatURIForDisplay(this.url) + )} + </span>`; } - #unpinnedTabItemTemplate() { - const title = this.title; + dateTemplate() { const relativeString = this.relativeTime( this.time, this.dateTimeFormat, @@ -815,11 +675,81 @@ export class FxviewTabRow extends MozLitElement { !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS ); const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat); + return html`<span class="fxview-tab-row-date" id="fxview-tab-row-date"> + <span + ?hidden=${relativeString || !dateString} + data-l10n-id=${ifDefined(dateString)} + data-l10n-args=${ifDefined(dateArgs)} + ></span> + <span ?hidden=${!relativeString}>${relativeString}</span> + </span>`; + } + + timeTemplate() { const timeString = this.timeFluentId(this.dateTimeFormat); const time = this.time; const timeArgs = JSON.stringify({ time }); + return html`<span + class="fxview-tab-row-time" + id="fxview-tab-row-time" + ?hidden=${!timeString} + data-timestamp=${ifDefined(this.time)} + data-l10n-id=${ifDefined(timeString)} + data-l10n-args=${ifDefined(timeArgs)} + > + </span>`; + } - return html`<a + secondaryButtonTemplate() { + return html`${when( + this.secondaryL10nId && this.secondaryActionHandler, + () => html`<moz-button + type="icon ghost" + class=${classMap({ + "fxview-tab-row-button": true, + [this.secondaryActionClass]: this.secondaryActionClass, + })} + id="fxview-tab-row-secondary-button" + data-l10n-id=${this.secondaryL10nId} + data-l10n-args=${ifDefined(this.secondaryL10nArgs)} + aria-haspopup=${ifDefined(this.hasPopup)} + @click=${this.secondaryActionHandler} + tabindex="${this.active && + this.currentActiveElementId === "fxview-tab-row-secondary-button" + ? "0" + : "-1"}" + ></moz-button>` + )}`; + } + + tertiaryButtonTemplate() { + return html`${when( + this.tertiaryL10nId && this.tertiaryActionHandler, + () => html`<moz-button + type="icon ghost" + class=${classMap({ + "fxview-tab-row-button": true, + [this.tertiaryActionClass]: this.tertiaryActionClass, + })} + id="fxview-tab-row-tertiary-button" + data-l10n-id=${this.tertiaryL10nId} + data-l10n-args=${ifDefined(this.tertiaryL10nArgs)} + aria-haspopup=${ifDefined(this.hasPopup)} + @click=${this.tertiaryActionHandler} + tabindex="${this.active && + this.currentActiveElementId === "fxview-tab-row-tertiary-button" + ? "0" + : "-1"}" + ></moz-button>` + )}`; + } +} + +export class FxviewTabRow extends FxviewTabRowBase { + render() { + return html` + ${this.stylesheets()} + <a href=${ifDefined(this.url)} class="fxview-tab-row-main" id="fxview-tab-row-main" @@ -833,176 +763,16 @@ export class FxviewTabRow extends MozLitElement { @keydown=${this.primaryActionHandler} title=${!this.primaryL10nId ? this.url : null} > - ${this.#faviconTemplate()} - <span - class="fxview-tab-row-title text-truncated-ellipsis" - id="fxview-tab-row-title" - dir="auto" - > - ${when( - this.searchQuery, - () => this.#highlightSearchMatches(this.searchQuery, title), - () => title - )} - </span> - <span class=${this.getContainerClasses().join(" ")}></span> - <span - class="fxview-tab-row-url text-truncated-ellipsis" - id="fxview-tab-row-url" - ?hidden=${this.compact} - > - ${when( - this.searchQuery, - () => - this.#highlightSearchMatches( - this.searchQuery, - this.formatURIForDisplay(this.url) - ), - () => this.formatURIForDisplay(this.url) - )} - </span> - <span - class="fxview-tab-row-date" - id="fxview-tab-row-date" - ?hidden=${this.compact} - > - <span - ?hidden=${relativeString || !dateString} - data-l10n-id=${ifDefined(dateString)} - data-l10n-args=${ifDefined(dateArgs)} - ></span> - <span ?hidden=${!relativeString}>${relativeString}</span> - </span> - <span - class="fxview-tab-row-time" - id="fxview-tab-row-time" - ?hidden=${this.compact || !timeString} - data-timestamp=${ifDefined(this.time)} - data-l10n-id=${ifDefined(timeString)} - data-l10n-args=${ifDefined(timeArgs)} - > - </span> + ${this.faviconTemplate()} ${this.titleTemplate()} + ${when( + !this.compact, + () => html`${this.urlTemplate()} ${this.dateTemplate()} + ${this.timeTemplate()}` + )} </a> - ${when( - this.indicators?.includes("soundplaying") || - this.indicators?.includes("muted"), - () => html`<button - class=fxview-tab-row-button ghost-button icon-button semi-transparent" - id="fxview-tab-row-media-button" - data-l10n-id=${ - this.indicators?.includes("muted") - ? "fxviewtabrow-unmute-tab-button-no-context" - : "fxviewtabrow-mute-tab-button-no-context" - } - muted=${this.indicators?.includes("muted")} - soundplaying=${ - this.indicators?.includes("soundplaying") && - !this.indicators?.includes("muted") - } - @click=${this.muteOrUnmuteTab} - tabindex="${ - this.active && - this.currentActiveElementId === "fxview-tab-row-media-button" - ? "0" - : "-1" - }" - ></button>`, - () => html`<span></span>` - )} - ${when( - this.secondaryL10nId && this.secondaryActionHandler, - () => html`<button - class=${classMap({ - "fxview-tab-row-button": true, - "ghost-button": true, - "icon-button": true, - "semi-transparent": true, - [this.secondaryActionClass]: this.secondaryActionClass, - })} - id="fxview-tab-row-secondary-button" - data-l10n-id=${this.secondaryL10nId} - data-l10n-args=${ifDefined(this.secondaryL10nArgs)} - aria-haspopup=${ifDefined(this.hasPopup)} - @click=${this.secondaryActionHandler} - tabindex="${this.active && - this.currentActiveElementId === "fxview-tab-row-secondary-button" - ? "0" - : "-1"}" - ></button>` - )} - ${when( - this.tertiaryL10nId && this.tertiaryActionHandler, - () => html`<button - class=${classMap({ - "fxview-tab-row-button": true, - "ghost-button": true, - "icon-button": true, - "semi-transparent": true, - [this.tertiaryActionClass]: this.tertiaryActionClass, - })} - id="fxview-tab-row-tertiary-button" - data-l10n-id=${this.tertiaryL10nId} - data-l10n-args=${ifDefined(this.tertiaryL10nArgs)} - aria-haspopup=${ifDefined(this.hasPopup)} - @click=${this.tertiaryActionHandler} - tabindex="${this.active && - this.currentActiveElementId === "fxview-tab-row-tertiary-button" - ? "0" - : "-1"}" - ></button>` - )}`; - } - - render() { - return html` - ${when( - this.containerObj, - () => html` - <link - rel="stylesheet" - href="chrome://browser/content/usercontext/usercontext.css" - /> - ` - )} - <link - rel="stylesheet" - href="chrome://global/skin/in-content/common.css" - /> - <link - rel="stylesheet" - href="chrome://browser/content/firefoxview/fxview-tab-row.css" - /> - ${when( - this.pinnedTabsGridView && this.indicators?.includes("pinned"), - this.#pinnedTabItemTemplate.bind(this), - this.#unpinnedTabItemTemplate.bind(this) - )} + ${this.secondaryButtonTemplate()} ${this.tertiaryButtonTemplate()} `; } - - /** - * Find all matches of query within the given string, and compute the result - * to be rendered. - * - * @param {string} query - * @param {string} string - */ - #highlightSearchMatches(query, string) { - const fragments = []; - const regex = RegExp(escapeRegExp(query), "dgi"); - let prevIndexEnd = 0; - let result; - while ((result = regex.exec(string)) !== null) { - const [indexStart, indexEnd] = result.indices[0]; - fragments.push(string.substring(prevIndexEnd, indexStart)); - fragments.push( - html`<strong>${string.substring(indexStart, indexEnd)}</strong>` - ); - prevIndexEnd = regex.lastIndex; - } - fragments.push(string.substring(prevIndexEnd)); - return fragments; - } } customElements.define("fxview-tab-row", FxviewTabRow); @@ -1040,10 +810,16 @@ export class VirtualList extends MozLitElement { this.isSubList = false; this.isVisible = false; this.intersectionObserver = new IntersectionObserver( - ([entry]) => (this.isVisible = entry.isIntersecting), + ([entry]) => { + this.isVisible = entry.isIntersecting; + }, { root: this.ownerDocument } ); - this.resizeObserver = new ResizeObserver(([entry]) => { + this.selfResizeObserver = new ResizeObserver(() => { + // Trigger the intersection observer once the tab rows have rendered + this.triggerIntersectionObserver(); + }); + this.childResizeObserver = new ResizeObserver(([entry]) => { if (entry.contentRect?.height > 0) { // Update properties on top-level virtual-list this.parentElement.itemHeightEstimate = entry.contentRect.height; @@ -1058,7 +834,8 @@ export class VirtualList extends MozLitElement { disconnectedCallback() { super.disconnectedCallback(); this.intersectionObserver.disconnect(); - this.resizeObserver.disconnect(); + this.childResizeObserver.disconnect(); + this.selfResizeObserver.disconnect(); } triggerIntersectionObserver() { @@ -1090,7 +867,6 @@ export class VirtualList extends MozLitElement { this.items.slice(i, i + this.maxRenderCountEstimate) ); } - this.triggerIntersectionObserver(); } } @@ -1103,13 +879,17 @@ export class VirtualList extends MozLitElement { firstUpdated() { this.intersectionObserver.observe(this); + this.selfResizeObserver.observe(this); if (this.isSubList && this.children[0]) { - this.resizeObserver.observe(this.children[0]); + this.childResizeObserver.observe(this.children[0]); } } updated(changedProperties) { this.updateListHeight(changedProperties); + if (changedProperties.has("items") && !this.isSubList) { + this.triggerIntersectionObserver(); + } } updateListHeight(changedProperties) { @@ -1157,5 +937,4 @@ export class VirtualList extends MozLitElement { return ""; } } - customElements.define("virtual-list", VirtualList); diff --git a/browser/components/firefoxview/fxview-tab-row.css b/browser/components/firefoxview/fxview-tab-row.css index 219d7e8aa2..c1c8f967a7 100644 --- a/browser/components/firefoxview/fxview-tab-row.css +++ b/browser/components/firefoxview/fxview-tab-row.css @@ -2,9 +2,11 @@ * 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/. */ +@import url("chrome://global/skin/design-system/text-and-typography.css"); + :host { - --fxviewtabrow-element-background-hover: color-mix(in srgb, currentColor 14%, transparent); - --fxviewtabrow-element-background-active: color-mix(in srgb, currentColor 21%, transparent); + --fxviewtabrow-element-background-hover: var(--button-background-color-ghost-hover); + --fxviewtabrow-element-background-active: var(--button-background-color-ghost-active); display: grid; grid-template-columns: subgrid; grid-column: span 9; @@ -12,7 +14,7 @@ border-radius: 4px; } -@media (prefers-contrast) { +@media (forced-colors) { :host { --fxviewtabrow-element-background-hover: ButtonText; --fxviewtabrow-element-background-active: ButtonText; @@ -32,115 +34,42 @@ cursor: pointer; text-decoration: none; - :host(.pinned) & { - padding: var(--space-small); - min-width: unset; - margin: 0; + :host([compact]) & { + grid-template-columns: min-content auto; } } .fxview-tab-row-main, .fxview-tab-row-main:visited, -.fxview-tab-row-main:hover:active, -.fxview-tab-row-button { +.fxview-tab-row-main:hover:active { color: inherit; } -.fxview-tab-row-main:hover, -.fxview-tab-row-button.ghost-button.icon-button:enabled:hover { +.fxview-tab-row-main:hover { background-color: var(--fxviewtabrow-element-background-hover); color: var(--fxviewtabrow-text-color-hover); - - & .fxview-tab-row-favicon-wrapper .fxview-tab-row-favicon::after { - stroke: var(--fxview-indicator-stroke-color-hover); - } } -.fxview-tab-row-main:hover:active, -.fxview-tab-row-button.ghost-button.icon-button:enabled:hover:active { +.fxview-tab-row-main:hover:active { background-color: var(--fxviewtabrow-element-background-active); } -@media (prefers-contrast) { - a.fxview-tab-row-main, - a.fxview-tab-row-main:hover, - a.fxview-tab-row-main:active { +@media (forced-colors) { + .fxview-tab-row-main, + .fxview-tab-row-main:hover, + .fxview-tab-row-main:active { background-color: transparent; border: 1px solid LinkText; color: LinkText; } - a.fxview-tab-row-main:visited, - a.fxview-tab-row-main:visited:hover { + .fxview-tab-row-main:visited, + .fxview-tab-row-main:visited:hover { border: 1px solid VisitedText; color: VisitedText; } } -.fxview-tab-row-favicon-wrapper { - height: 16px; - position: relative; - - .fxview-tab-row-favicon::after, - .fxview-tab-row-button::after, - &.pinned .fxview-tab-row-pinned-media-button { - display: block; - content: ""; - background-size: 12px; - background-position: center; - background-repeat: no-repeat; - position: relative; - height: 12px; - width: 12px; - -moz-context-properties: fill, stroke; - fill: currentColor; - stroke: var(--fxview-background-color-secondary); - } - - &:is(.pinnedOnNewTab, .bookmark):not(.attention) .fxview-tab-row-favicon::after { - inset-block-start: 9px; - inset-inline-end: -6px; - } - - &.pinnedOnNewTab .fxview-tab-row-favicon::after, - &.pinnedOnNewTab .fxview-tab-row-button::after { - background-image: url("chrome://browser/skin/pin-12.svg"); - } - - &.bookmark .fxview-tab-row-favicon::after, - &.bookmark .fxview-tab-row-button::after { - background-image: url("chrome://browser/skin/bookmark-12.svg"); - fill: var(--fxview-primary-action-background); - } - - &.attention .fxview-tab-row-favicon::after, - &.attention .fxview-tab-row-button::after { - background-image: radial-gradient(circle, light-dark(rgb(42, 195, 162), rgb(84, 255, 189)), light-dark(rgb(42, 195, 162), rgb(84, 255, 189)) 2px, transparent 2px); - height: 4px; - width: 100%; - inset-block-start: 20px; - } - - &.pinned .fxview-tab-row-pinned-media-button { - inset-block-start: -10px; - inset-inline-end: -10px; - border-radius: 100%; - background-color: var(--fxview-background-color-secondary); - padding: 6px; - min-width: 0; - min-height: 0; - position: absolute; - - &[muted="true"] { - background-image: url("chrome://global/skin/media/audio-muted.svg"); - } - - &[soundplaying="true"] { - background-image: url("chrome://global/skin/media/audio.svg"); - } - } -} - .fxview-tab-row-favicon { background-size: cover; -moz-context-properties: fill; @@ -155,15 +84,6 @@ text-align: match-parent; } -.fxview-tab-row-container-indicator { - height: 16px; - width: 16px; - background-image: var(--identity-icon); - background-size: cover; - -moz-context-properties: fill; - fill: var(--identity-icon-color); -} - .fxview-tab-row-url { color: var(--text-color-deemphasized); text-decoration-line: underline; @@ -182,62 +102,22 @@ font-weight: 400; } -.fxview-tab-row-button { - margin: 0; - cursor: pointer; - min-width: 0; - background-color: transparent; - - &[muted="true"], - &[soundplaying="true"] { - background-size: 16px; - background-repeat: no-repeat; - background-position: center; - -moz-context-properties: fill; - fill: currentColor; - } - - &[muted="true"] { - background-image: url("chrome://global/skin/media/audio-muted.svg"); - } - - &[soundplaying="true"] { - background-image: url("chrome://global/skin/media/audio.svg"); - } - - &.dismiss-button { - background-image: url("chrome://global/skin/icons/close.svg"); - } - - &.options-button { - background-image: url("chrome://global/skin/icons/more.svg"); - } +.fxview-tab-row-button::part(button) { + color: var(--fxview-text-primary-color) } -@media (prefers-contrast) { - .fxview-tab-row-button, - button.fxview-tab-row-main { - border: 1px solid ButtonText; - color: ButtonText; - } +.fxview-tab-row-button[muted="true"]::part(button) { + background-image: url("chrome://global/skin/media/audio-muted.svg"); +} - .fxview-tab-row-button.ghost-button.icon-button:enabled:hover, - button.fxview-tab-row-main:enabled:hover { - border: 1px solid SelectedItem; - color: SelectedItem; - } +.fxview-tab-row-button[soundplaying="true"]::part(button) { + background-image: url("chrome://global/skin/media/audio.svg"); +} - .fxview-tab-row-button.ghost-button.icon-button:enabled:active, - button.fxview-tab-row-main:enabled:active { - color: SelectedItem; - } +.fxview-tab-row-button.dismiss-button::part(button) { + background-image: url("chrome://global/skin/icons/close.svg"); +} - .fxview-tab-row-button.ghost-button.icon-button:enabled, - .fxview-tab-row-button.ghost-button.icon-button:enabled:hover, - .fxview-tab-row-button.ghost-button.icon-button:enabled:active - button.fxview-tab-row-main:enabled, - button.fxview-tab-row-main:enabled:hover, - button.fxview-tab-row-main:enabled:active { - background-color: ButtonFace; - } +.fxview-tab-row-button.options-button::part(button) { + background-image: url("chrome://global/skin/icons/more.svg"); } diff --git a/browser/components/firefoxview/helpers.mjs b/browser/components/firefoxview/helpers.mjs index 3cb308a587..b206deef18 100644 --- a/browser/components/firefoxview/helpers.mjs +++ b/browser/components/firefoxview/helpers.mjs @@ -173,3 +173,20 @@ export function escapeHtmlEntities(text) { .replace(/"/g, """) .replace(/'/g, "'"); } + +export function navigateToLink(e) { + let currentWindow = + e.target.ownerGlobal.browsingContext.embedderWindowGlobal.browsingContext + .window; + if (currentWindow.openTrustedLinkIn) { + let where = lazy.BrowserUtils.whereToOpenLink( + e.detail.originalEvent, + false, + true + ); + if (where == "current") { + where = "tab"; + } + currentWindow.openTrustedLinkIn(e.originalTarget.url, where); + } +} diff --git a/browser/components/firefoxview/history.css b/browser/components/firefoxview/history.css index dd2786a8c7..a10291ddb5 100644 --- a/browser/components/firefoxview/history.css +++ b/browser/components/firefoxview/history.css @@ -51,19 +51,8 @@ cursor: pointer; } -.import-history-banner .close { +moz-button.close::part(button) { background-image: url("chrome://global/skin/icons/close-12.svg"); - background-repeat: no-repeat; - background-position: center center; - -moz-context-properties: fill; - fill: currentColor; - min-width: auto; - min-height: auto; - width: 24px; - height: 24px; - margin: 0; - padding: 0; - flex-shrink: 0; } dialog { diff --git a/browser/components/firefoxview/history.mjs b/browser/components/firefoxview/history.mjs index 1fe028449b..478422d49b 100644 --- a/browser/components/firefoxview/history.mjs +++ b/browser/components/firefoxview/history.mjs @@ -7,18 +7,21 @@ import { ifDefined, when, } from "chrome://global/content/vendor/lit.all.mjs"; -import { escapeHtmlEntities, isSearchEnabled } from "./helpers.mjs"; +import { + escapeHtmlEntities, + isSearchEnabled, + navigateToLink, +} from "./helpers.mjs"; import { ViewPage } from "./viewpage.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://browser/content/migration/migration-wizard.mjs"; +import { HistoryController } from "./HistoryController.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", - FirefoxViewPlacesQuery: - "resource:///modules/firefox-view-places-query.sys.mjs", - PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", }); @@ -26,13 +29,6 @@ let XPCOMUtils = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ).XPCOMUtils; -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "maxRowsPref", - "browser.firefox-view.max-history-rows", - -1 -); - const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history"; const IMPORT_HISTORY_DISMISSED_PREF = @@ -44,35 +40,30 @@ class HistoryInView extends ViewPage { constructor() { super(); this._started = false; - this.allHistoryItems = new Map(); - this.historyMapByDate = []; - this.historyMapBySite = []; // Setting maxTabsLength to -1 for no max this.maxTabsLength = -1; - this.placesQuery = new lazy.FirefoxViewPlacesQuery(); - this.searchQuery = ""; - this.searchResults = null; - this.sortOption = "date"; this.profileAge = 8; this.fullyUpdated = false; this.cumulativeSearches = 0; } + controller = new HistoryController(this, { + searchResultsLimit: SEARCH_RESULTS_LIMIT, + }); + start() { if (this._started) { return; } this._started = true; - this.#updateAllHistoryItems(); - this.placesQuery.observeHistory(data => this.#updateAllHistoryItems(data)); + this.controller.updateAllHistoryItems(); this.toggleVisibilityInCardContainer(); } async connectedCallback() { super.connectedCallback(); - await this.updateHistoryData(); XPCOMUtils.defineLazyPreferenceGetter( this, "importHistoryDismissedPref", @@ -91,6 +82,7 @@ class HistoryInView extends ViewPage { this.requestUpdate(); } ); + if (!this.importHistoryDismissedPref && !this.hasImportedHistoryPrefs) { let profileAccessor = await lazy.ProfileAge(); let profileCreateTime = await profileAccessor.created; @@ -106,7 +98,6 @@ class HistoryInView extends ViewPage { return; } this._started = false; - this.placesQuery.close(); this.toggleVisibilityInCardContainer(); } @@ -120,32 +111,6 @@ class HistoryInView extends ViewPage { ); } - async #updateAllHistoryItems(allHistoryItems) { - if (allHistoryItems) { - this.allHistoryItems = allHistoryItems; - } else { - await this.updateHistoryData(); - } - this.resetHistoryMaps(); - this.lists.forEach(list => list.requestUpdate()); - await this.#updateSearchResults(); - } - - async #updateSearchResults() { - if (this.searchQuery) { - try { - this.searchResults = await this.placesQuery.searchHistory( - this.searchQuery, - SEARCH_RESULTS_LIMIT - ); - } catch (e) { - // Connection interrupted, ignore. - } - } else { - this.searchResults = null; - } - } - viewVisibleCallback() { this.start(); } @@ -166,14 +131,8 @@ class HistoryInView extends ViewPage { }; static properties = { - ...ViewPage.properties, - allHistoryItems: { type: Map }, - historyMapByDate: { type: Array }, - historyMapBySite: { type: Array }, // Making profileAge a reactive property for testing profileAge: { type: Number }, - searchResults: { type: Array }, - sortOption: { type: String }, }; async getUpdateComplete() { @@ -181,70 +140,8 @@ class HistoryInView extends ViewPage { await Promise.all(Array.from(this.cards).map(card => card.updateComplete)); } - async updateHistoryData() { - this.allHistoryItems = await this.placesQuery.getHistory({ - daysOld: 60, - limit: lazy.maxRowsPref, - sortBy: this.sortOption, - }); - } - - resetHistoryMaps() { - this.historyMapByDate = []; - this.historyMapBySite = []; - } - - createHistoryMaps() { - if (this.sortOption === "date" && !this.historyMapByDate.length) { - const { - visitsFromToday, - visitsFromYesterday, - visitsByDay, - visitsByMonth, - } = this.placesQuery; - - // Add visits from today and yesterday. - if (visitsFromToday.length) { - this.historyMapByDate.push({ - l10nId: "firefoxview-history-date-today", - items: visitsFromToday, - }); - } - if (visitsFromYesterday.length) { - this.historyMapByDate.push({ - l10nId: "firefoxview-history-date-yesterday", - items: visitsFromYesterday, - }); - } - - // Add visits from this month, grouped by day. - visitsByDay.forEach(visits => { - this.historyMapByDate.push({ - l10nId: "firefoxview-history-date-this-month", - items: visits, - }); - }); - - // Add visits from previous months, grouped by month. - visitsByMonth.forEach(visits => { - this.historyMapByDate.push({ - l10nId: "firefoxview-history-date-prev-month", - items: visits, - }); - }); - } else if (this.sortOption === "site" && !this.historyMapBySite.length) { - this.historyMapBySite = Array.from( - this.allHistoryItems.entries(), - ([domain, items]) => ({ - domain, - items, - l10nId: domain ? null : "firefoxview-history-site-localhost", - }) - ).sort((a, b) => a.domain.localeCompare(b.domain)); - } - } - onPrimaryAction(e) { + navigateToLink(e); // Record telemetry Services.telemetry.recordEvent( "firefoxview_next", @@ -254,26 +151,13 @@ class HistoryInView extends ViewPage { {} ); - if (this.searchQuery) { + if (this.controller.searchQuery) { const searchesHistogram = Services.telemetry.getKeyedHistogramById( "FIREFOX_VIEW_CUMULATIVE_SEARCHES" ); searchesHistogram.add("history", this.cumulativeSearches); this.cumulativeSearches = 0; } - - let currentWindow = this.getWindow(); - if (currentWindow.openTrustedLinkIn) { - let where = lazy.BrowserUtils.whereToOpenLink( - e.detail.originalEvent, - false, - true - ); - if (where == "current") { - where = "tab"; - } - currentWindow.openTrustedLinkIn(e.originalTarget.url, where); - } } onSecondaryAction(e) { @@ -282,24 +166,29 @@ class HistoryInView extends ViewPage { } deleteFromHistory(e) { - lazy.PlacesUtils.history.remove(this.triggerNode.url); + this.controller.deleteFromHistory(); this.recordContextMenuTelemetry("delete-from-history", e); } async onChangeSortOption(e) { - this.sortOption = e.target.value; + await this.controller.onChangeSortOption(e); Services.telemetry.recordEvent( "firefoxview_next", "sort_history", "tabs", null, { - sort_type: this.sortOption, - search_start: this.searchQuery ? "true" : "false", + sort_type: this.controller.sortOption, + search_start: this.controller.searchQuery ? "true" : "false", } ); - await this.updateHistoryData(); - await this.#updateSearchResults(); + } + + async onSearchQuery(e) { + await this.controller.onSearchQuery(e); + this.cumulativeSearches = this.controller.searchQuery + ? this.cumulativeSearches + 1 + : 0; } showAllHistory() { @@ -396,9 +285,9 @@ class HistoryInView extends ViewPage { * The template to use for cards-container. */ get cardsTemplate() { - if (this.searchResults) { + if (this.controller.searchResults) { return this.#searchResultsTemplate(); - } else if (this.allHistoryItems.size) { + } else if (this.controller.allHistoryItems.size) { return this.#historyCardsTemplate(); } return this.#emptyMessageTemplate(); @@ -406,8 +295,11 @@ class HistoryInView extends ViewPage { #historyCardsTemplate() { let cardsTemplate = []; - if (this.sortOption === "date" && this.historyMapByDate.length) { - this.historyMapByDate.forEach(historyItem => { + if ( + this.controller.sortOption === "date" && + this.controller.historyMapByDate.length + ) { + this.controller.historyMapByDate.forEach(historyItem => { if (historyItem.items.length) { let dateArg = JSON.stringify({ date: historyItem.items[0].time }); cardsTemplate.push(html`<card-container> @@ -424,7 +316,7 @@ class HistoryInView extends ViewPage { : "time"} hasPopup="menu" maxTabsLength=${this.maxTabsLength} - .tabItems=${historyItem.items} + .tabItems=${[...historyItem.items]} @fxview-tab-list-primary-action=${this.onPrimaryAction} @fxview-tab-list-secondary-action=${this.onSecondaryAction} > @@ -433,8 +325,8 @@ class HistoryInView extends ViewPage { </card-container>`); } }); - } else if (this.historyMapBySite.length) { - this.historyMapBySite.forEach(historyItem => { + } else if (this.controller.historyMapBySite.length) { + this.controller.historyMapBySite.forEach(historyItem => { if (historyItem.items.length) { cardsTemplate.push(html`<card-container> <h3 slot="header" data-l10n-id="${ifDefined(historyItem.l10nId)}"> @@ -446,7 +338,7 @@ class HistoryInView extends ViewPage { dateTimeFormat="dateTime" hasPopup="menu" maxTabsLength=${this.maxTabsLength} - .tabItems=${historyItem.items} + .tabItems=${[...historyItem.items]} @fxview-tab-list-primary-action=${this.onPrimaryAction} @fxview-tab-list-secondary-action=${this.onSecondaryAction} > @@ -504,17 +396,17 @@ class HistoryInView extends ViewPage { slot="header" data-l10n-id="firefoxview-search-results-header" data-l10n-args=${JSON.stringify({ - query: escapeHtmlEntities(this.searchQuery), + query: escapeHtmlEntities(this.controller.searchQuery), })} ></h3> ${when( - this.searchResults.length, + this.controller.searchResults.length, () => html`<h3 slot="secondary-header" data-l10n-id="firefoxview-search-results-count" data-l10n-args="${JSON.stringify({ - count: this.searchResults.length, + count: this.controller.searchResults.length, })}" ></h3>` )} @@ -524,10 +416,11 @@ class HistoryInView extends ViewPage { dateTimeFormat="dateTime" hasPopup="menu" maxTabsLength="-1" - .searchQuery=${this.searchQuery} - .tabItems=${this.searchResults} + .searchQuery=${this.controller.searchQuery} + .tabItems=${this.controller.searchResults} @fxview-tab-list-primary-action=${this.onPrimaryAction} @fxview-tab-list-secondary-action=${this.onSecondaryAction} + .searchInProgress=${this.controller.placesQuery.searchInProgress} > ${this.panelListTemplate()} </fxview-tab-list> @@ -569,7 +462,7 @@ class HistoryInView extends ViewPage { id="sort-by-date" name="history-sort-option" value="date" - ?checked=${this.sortOption === "date"} + ?checked=${this.controller.sortOption === "date"} @click=${this.onChangeSortOption} /> <label @@ -583,7 +476,7 @@ class HistoryInView extends ViewPage { id="sort-by-site" name="history-sort-option" value="site" - ?checked=${this.sortOption === "site"} + ?checked=${this.controller.sortOption === "site"} @click=${this.onChangeSortOption} /> <label @@ -612,11 +505,12 @@ class HistoryInView extends ViewPage { data-l10n-id="firefoxview-choose-browser-button" @click=${this.openMigrationWizard} ></button> - <button - class="close ghost-button" + <moz-button + class="close" + type="icon ghost" data-l10n-id="firefoxview-import-history-close-button" @click=${this.dismissImportHistory} - ></button> + ></moz-button> </div> </div> </card-container> @@ -624,32 +518,24 @@ class HistoryInView extends ViewPage { </div> <div class="show-all-history-footer" - ?hidden=${!this.allHistoryItems.size} + ?hidden=${!this.controller.allHistoryItems.size} > <button class="show-all-history-button" data-l10n-id="firefoxview-show-all-history" @click=${this.showAllHistory} - ?hidden=${this.searchResults} + ?hidden=${this.controller.searchResults} ></button> </div> `; } - async onSearchQuery(e) { - this.searchQuery = e.detail.query; - this.cumulativeSearches = this.searchQuery - ? this.cumulativeSearches + 1 - : 0; - this.#updateSearchResults(); - } - - willUpdate(changedProperties) { + willUpdate() { this.fullyUpdated = false; - if (this.allHistoryItems.size && !changedProperties.has("sortOption")) { + if (this.controller.allHistoryItems.size) { // onChangeSortOption() will update history data once it has been fetched // from the API. - this.createHistoryMaps(); + this.controller.createHistoryMaps(); } } } diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn index 1e5cc3e690..8bf3597aa5 100644 --- a/browser/components/firefoxview/jar.mn +++ b/browser/components/firefoxview/jar.mn @@ -9,6 +9,7 @@ browser.jar: content/browser/firefoxview/firefoxview.mjs content/browser/firefoxview/history.css content/browser/firefoxview/history.mjs + content/browser/firefoxview/HistoryController.mjs content/browser/firefoxview/opentabs.mjs content/browser/firefoxview/view-opentabs.css content/browser/firefoxview/syncedtabs.mjs @@ -23,6 +24,9 @@ browser.jar: content/browser/firefoxview/fxview-tab-list.css content/browser/firefoxview/fxview-tab-list.mjs content/browser/firefoxview/fxview-tab-row.css + content/browser/firefoxview/opentabs-tab-list.css + content/browser/firefoxview/opentabs-tab-list.mjs + content/browser/firefoxview/opentabs-tab-row.css content/browser/firefoxview/recentlyclosed.mjs content/browser/firefoxview/viewpage.mjs content/browser/firefoxview/history-empty.svg (content/history-empty.svg) diff --git a/browser/components/firefoxview/opentabs-tab-list.css b/browser/components/firefoxview/opentabs-tab-list.css new file mode 100644 index 0000000000..9245a0fada --- /dev/null +++ b/browser/components/firefoxview/opentabs-tab-list.css @@ -0,0 +1,32 @@ +/* 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/. */ + +.fxview-tab-list { + &.pinned { + display: flex; + flex-wrap: wrap; + + > virtual-list { + display: block; + } + + > opentabs-tab-row { + display: block; + margin-block-end: var(--space-xsmall); + } + } + + &.hasContainerTab { + grid-template-columns: min-content 3fr min-content 2fr 1fr 1fr min-content min-content; + } +} + +virtual-list { + grid-column: span 9; + + .top-padding, + .bottom-padding { + grid-column: span 9; + } +} diff --git a/browser/components/firefoxview/opentabs-tab-list.mjs b/browser/components/firefoxview/opentabs-tab-list.mjs new file mode 100644 index 0000000000..4b6d6b3c86 --- /dev/null +++ b/browser/components/firefoxview/opentabs-tab-list.mjs @@ -0,0 +1,593 @@ +/* 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/. */ + +import { + classMap, + html, + ifDefined, + styleMap, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { + FxviewTabListBase, + FxviewTabRowBase, +} from "chrome://browser/content/firefoxview/fxview-tab-list.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; + +const lazy = {}; +let XPCOMUtils; + +XPCOMUtils = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +).XPCOMUtils; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "virtualListEnabledPref", + "browser.firefox-view.virtual-list.enabled" +); + +/** + * A list of clickable tab items + * + * @property {boolean} pinnedTabsGridView - Whether to show pinned tabs in a grid view + */ + +export class OpenTabsTabList extends FxviewTabListBase { + constructor() { + super(); + this.pinnedTabsGridView = false; + this.pinnedTabs = []; + this.unpinnedTabs = []; + } + + static properties = { + pinnedTabsGridView: { type: Boolean }, + }; + + static queries = { + ...FxviewTabListBase.queries, + rowEls: { + all: "opentabs-tab-row", + }, + }; + + willUpdate(changes) { + this.activeIndex = Math.min( + Math.max(this.activeIndex, 0), + this.tabItems.length - 1 + ); + + if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) { + this.clearIntervalTimer(); + if (!this.updatesPaused && this.dateTimeFormat == "relative") { + this.startIntervalTimer(); + this.onIntervalUpdate(); + } + } + + // Move pinned tabs to the beginning of the list + if (this.pinnedTabsGridView) { + // Can set maxTabsLength to -1 to have no max + this.unpinnedTabs = this.tabItems.filter( + tab => !tab.indicators.includes("pinned") + ); + this.pinnedTabs = this.tabItems.filter(tab => + tab.indicators.includes("pinned") + ); + if (this.maxTabsLength > 0) { + this.unpinnedTabs = this.unpinnedTabs.slice(0, this.maxTabsLength); + } + this.tabItems = [...this.pinnedTabs, ...this.unpinnedTabs]; + } else if (this.maxTabsLength > 0) { + this.tabItems = this.tabItems.slice(0, this.maxTabsLength); + } + } + + /** + * Focuses the expected element (either the link or button) within fxview-tab-row + * The currently focused/active element ID within a row is stored in this.currentActiveElementId + */ + handleFocusElementInRow(e) { + let fxviewTabRow = e.target; + if (e.code == "ArrowUp") { + // Focus either the link or button of the previous row based on this.currentActiveElementId + e.preventDefault(); + if ( + (this.pinnedTabsGridView && + this.activeIndex >= this.pinnedTabs.length) || + !this.pinnedTabsGridView + ) { + this.focusPrevRow(); + } + } else if (e.code == "ArrowDown") { + // Focus either the link or button of the next row based on this.currentActiveElementId + e.preventDefault(); + if ( + this.pinnedTabsGridView && + this.activeIndex < this.pinnedTabs.length + ) { + this.focusIndex(this.pinnedTabs.length); + } else { + this.focusNextRow(); + } + } else if (e.code == "ArrowRight") { + // Focus either the link or the button in the current row and + // set this.currentActiveElementId to that element's ID + e.preventDefault(); + if (document.dir == "rtl") { + fxviewTabRow.moveFocusLeft(); + } else { + fxviewTabRow.moveFocusRight(); + } + } else if (e.code == "ArrowLeft") { + // Focus either the link or the button in the current row and + // set this.currentActiveElementId to that element's ID + e.preventDefault(); + if (document.dir == "rtl") { + fxviewTabRow.moveFocusRight(); + } else { + fxviewTabRow.moveFocusLeft(); + } + } + } + + async focusIndex(index) { + // Focus link or button of item + if ( + ((this.pinnedTabsGridView && index > this.pinnedTabs.length) || + !this.pinnedTabsGridView) && + lazy.virtualListEnabledPref + ) { + let row = this.rootVirtualListEl.getItem(index - this.pinnedTabs.length); + if (!row) { + return; + } + let subList = this.rootVirtualListEl.getSubListForItem( + index - this.pinnedTabs.length + ); + if (!subList) { + return; + } + this.activeIndex = index; + + // In Bug 1866845, these manual updates to the sublists should be removed + // and scrollIntoView() should also be iterated on so that we aren't constantly + // moving the focused item to the center of the viewport + for (const sublist of Array.from(this.rootVirtualListEl.children)) { + await sublist.requestUpdate(); + await sublist.updateComplete; + } + row.scrollIntoView({ block: "center" }); + row.focus(); + } else if (index >= 0 && index < this.rowEls?.length) { + this.rowEls[index].focus(); + this.activeIndex = index; + } + } + + #getTabListWrapperClasses() { + let wrapperClasses = ["fxview-tab-list"]; + let tabsToCheck = this.pinnedTabsGridView + ? this.unpinnedTabs + : this.tabItems; + if (tabsToCheck.some(tab => tab.containerObj)) { + wrapperClasses.push(`hasContainerTab`); + } + return wrapperClasses; + } + + itemTemplate = (tabItem, i) => { + let time; + if (tabItem.time || tabItem.closedAt) { + let stringTime = (tabItem.time || tabItem.closedAt).toString(); + // Different APIs return time in different units, so we use + // the length to decide if it's milliseconds or nanoseconds. + if (stringTime.length === 16) { + time = (tabItem.time || tabItem.closedAt) / 1000; + } else { + time = tabItem.time || tabItem.closedAt; + } + } + + return html`<opentabs-tab-row + ?active=${i == this.activeIndex} + class=${classMap({ + pinned: + this.pinnedTabsGridView && tabItem.indicators?.includes("pinned"), + })} + .currentActiveElementId=${this.currentActiveElementId} + .favicon=${tabItem.icon} + .compact=${this.compactRows} + .containerObj=${ifDefined(tabItem.containerObj)} + .indicators=${tabItem.indicators} + .pinnedTabsGridView=${ifDefined(this.pinnedTabsGridView)} + .primaryL10nId=${tabItem.primaryL10nId} + .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)} + .secondaryL10nId=${tabItem.secondaryL10nId} + .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)} + .tertiaryL10nId=${ifDefined(tabItem.tertiaryL10nId)} + .tertiaryL10nArgs=${ifDefined(tabItem.tertiaryL10nArgs)} + .secondaryActionClass=${this.secondaryActionClass} + .tertiaryActionClass=${ifDefined(this.tertiaryActionClass)} + .sourceClosedId=${ifDefined(tabItem.sourceClosedId)} + .sourceWindowId=${ifDefined(tabItem.sourceWindowId)} + .closedId=${ifDefined(tabItem.closedId || tabItem.closedId)} + role=${tabItem.pinned && this.pinnedTabsGridView ? "tab" : "listitem"} + .tabElement=${ifDefined(tabItem.tabElement)} + .time=${ifDefined(time)} + .title=${tabItem.title} + .url=${tabItem.url} + .searchQuery=${ifDefined(this.searchQuery)} + .timeMsPref=${ifDefined(this.timeMsPref)} + .hasPopup=${this.hasPopup} + .dateTimeFormat=${this.dateTimeFormat} + ></opentabs-tab-row>`; + }; + + render() { + if (this.searchQuery && this.tabItems.length === 0) { + return this.emptySearchResultsTemplate(); + } + return html` + ${this.stylesheets()} + <link + rel="stylesheet" + href="chrome://browser/content/firefoxview/opentabs-tab-list.css" + /> + ${when( + this.pinnedTabsGridView && this.pinnedTabs.length, + () => html` + <div + id="fxview-tab-list" + class="fxview-tab-list pinned" + data-l10n-id="firefoxview-pinned-tabs" + role="tablist" + @keydown=${this.handleFocusElementInRow} + > + ${this.pinnedTabs.map((tabItem, i) => + this.customItemTemplate + ? this.customItemTemplate(tabItem, i) + : this.itemTemplate(tabItem, i) + )} + </div> + ` + )} + <div + id="fxview-tab-list" + class=${this.#getTabListWrapperClasses().join(" ")} + data-l10n-id="firefoxview-tabs" + role="list" + @keydown=${this.handleFocusElementInRow} + > + ${when( + lazy.virtualListEnabledPref, + () => html` + <virtual-list + .activeIndex=${this.activeIndex} + .pinnedTabsIndexOffset=${this.pinnedTabsGridView + ? this.pinnedTabs.length + : 0} + .items=${this.pinnedTabsGridView + ? this.unpinnedTabs + : this.tabItems} + .template=${this.itemTemplate} + ></virtual-list> + `, + () => + html`${this.tabItems.map((tabItem, i) => + this.itemTemplate(tabItem, i) + )}` + )} + </div> + <slot name="menu"></slot> + `; + } +} +customElements.define("opentabs-tab-list", OpenTabsTabList); + +/** + * A tab item that displays favicon, title, url, and time of last access + * + * @property {object} containerObj - Info about an open tab's container if within one + * @property {string} indicators - An array of tab indicators if any are present + * @property {boolean} pinnedTabsGridView - Whether the show pinned tabs in a grid view + */ + +export class OpenTabsTabRow extends FxviewTabRowBase { + constructor() { + super(); + this.indicators = []; + this.pinnedTabsGridView = false; + } + + static properties = { + ...FxviewTabRowBase.properties, + containerObj: { type: Object }, + indicators: { type: Array }, + pinnedTabsGridView: { type: Boolean }, + }; + + static queries = { + ...FxviewTabRowBase.queries, + mediaButtonEl: "#fxview-tab-row-media-button", + pinnedTabButtonEl: "moz-button#fxview-tab-row-main", + }; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener("keydown", this.handleKeydown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener("keydown", this.handleKeydown); + } + + handleKeydown(e) { + if ( + this.active && + this.pinnedTabsGridView && + this.indicators?.includes("pinned") && + e.key === "m" && + e.ctrlKey + ) { + this.muteOrUnmuteTab(); + } + } + + moveFocusRight() { + let tabList = this.getRootNode().host; + if (this.pinnedTabsGridView && this.indicators?.includes("pinned")) { + tabList.focusNextRow(); + } else if ( + (this.indicators?.includes("soundplaying") || + this.indicators?.includes("muted")) && + this.currentActiveElementId === "fxview-tab-row-main" + ) { + this.focusMediaButton(); + } else if ( + this.currentActiveElementId === "fxview-tab-row-media-button" || + this.currentActiveElementId === "fxview-tab-row-main" + ) { + this.focusSecondaryButton(); + } else if ( + this.tertiaryButtonEl && + this.currentActiveElementId === "fxview-tab-row-secondary-button" + ) { + this.focusTertiaryButton(); + } + } + + moveFocusLeft() { + let tabList = this.getRootNode().host; + if ( + this.pinnedTabsGridView && + (this.indicators?.includes("pinned") || + (tabList.currentActiveElementId === "fxview-tab-row-main" && + tabList.activeIndex === tabList.pinnedTabs.length)) + ) { + tabList.focusPrevRow(); + } else if ( + tabList.currentActiveElementId === "fxview-tab-row-tertiary-button" + ) { + this.focusSecondaryButton(); + } else if ( + (this.indicators?.includes("soundplaying") || + this.indicators?.includes("muted")) && + tabList.currentActiveElementId === "fxview-tab-row-secondary-button" + ) { + this.focusMediaButton(); + } else { + this.focusLink(); + } + } + + focusMediaButton() { + let tabList = this.getRootNode().host; + this.mediaButtonEl.focus(); + tabList.currentActiveElementId = this.mediaButtonEl.id; + } + + #secondaryActionHandler(event) { + if ( + (this.pinnedTabsGridView && + this.indicators?.includes("pinned") && + event.type == "contextmenu") || + (event.type == "click" && event.detail && !event.altKey) || + // detail=0 is from keyboard + (event.type == "click" && !event.detail) + ) { + event.preventDefault(); + this.dispatchEvent( + new CustomEvent("fxview-tab-list-secondary-action", { + bubbles: true, + composed: true, + detail: { originalEvent: event, item: this }, + }) + ); + } + } + + #faviconTemplate() { + return html`<span + class="${classMap({ + "fxview-tab-row-favicon-wrapper": true, + pinned: this.indicators?.includes("pinned"), + pinnedOnNewTab: this.indicators?.includes("pinnedOnNewTab"), + attention: this.indicators?.includes("attention"), + bookmark: this.indicators?.includes("bookmark"), + })}" + > + <span + class="fxview-tab-row-favicon icon" + id="fxview-tab-row-favicon" + style=${styleMap({ + backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`, + })} + ></span> + ${when( + this.pinnedTabsGridView && + this.indicators?.includes("pinned") && + (this.indicators?.includes("muted") || + this.indicators?.includes("soundplaying")), + () => html` + <button + class="fxview-tab-row-pinned-media-button" + id="fxview-tab-row-media-button" + tabindex="-1" + data-l10n-id=${this.indicators?.includes("muted") + ? "fxviewtabrow-unmute-tab-button-no-context" + : "fxviewtabrow-mute-tab-button-no-context"} + muted=${this.indicators?.includes("muted")} + soundplaying=${this.indicators?.includes("soundplaying") && + !this.indicators?.includes("muted")} + @click=${this.muteOrUnmuteTab} + ></button> + ` + )} + </span>`; + } + + #getContainerClasses() { + let containerClasses = ["fxview-tab-row-container-indicator", "icon"]; + if (this.containerObj) { + let { icon, color } = this.containerObj; + containerClasses.push(`identity-icon-${icon}`); + containerClasses.push(`identity-color-${color}`); + } + return containerClasses; + } + + muteOrUnmuteTab(e) { + e?.preventDefault(); + // If the tab has no sound playing, the mute/unmute button will be removed when toggled. + // We should move the focus to the right in that case. This does not apply to pinned tabs + // on the Open Tabs page. + let shouldMoveFocus = + (!this.pinnedTabsGridView || + (!this.indicators.includes("pinned") && this.pinnedTabsGridView)) && + this.mediaButtonEl && + !this.indicators.includes("soundplaying") && + this.currentActiveElementId === "fxview-tab-row-media-button"; + + // detail=0 is from keyboard + if (e?.type == "click" && !e?.detail && shouldMoveFocus) { + if (document.dir == "rtl") { + this.moveFocusLeft(); + } else { + this.moveFocusRight(); + } + } + this.tabElement.toggleMuteAudio(); + } + + #mediaButtonTemplate() { + return html`${when( + this.indicators?.includes("soundplaying") || + this.indicators?.includes("muted"), + () => html`<moz-button + type="icon ghost" + class="fxview-tab-row-button" + id="fxview-tab-row-media-button" + data-l10n-id=${this.indicators?.includes("muted") + ? "fxviewtabrow-unmute-tab-button-no-context" + : "fxviewtabrow-mute-tab-button-no-context"} + muted=${this.indicators?.includes("muted")} + soundplaying=${this.indicators?.includes("soundplaying") && + !this.indicators?.includes("muted")} + @click=${this.muteOrUnmuteTab} + tabindex="${this.active && + this.currentActiveElementId === "fxview-tab-row-media-button" + ? "0" + : "-1"}" + ></moz-button>`, + () => html`<span></span>` + )}`; + } + + #containerIndicatorTemplate() { + let tabList = this.getRootNode().host; + let tabsToCheck = tabList.pinnedTabsGridView + ? tabList.unpinnedTabs + : tabList.tabItems; + return html`${when( + tabsToCheck.some(tab => tab.containerObj), + () => html`<span class=${this.#getContainerClasses().join(" ")}></span>` + )}`; + } + + #pinnedTabItemTemplate() { + return html` + <moz-button + type="icon ghost" + id="fxview-tab-row-main" + aria-haspopup=${ifDefined(this.hasPopup)} + data-l10n-id=${ifDefined(this.primaryL10nId)} + data-l10n-args=${ifDefined(this.primaryL10nArgs)} + tabindex=${this.active && + this.currentActiveElementId === "fxview-tab-row-main" + ? "0" + : "-1"} + role="tab" + @click=${this.primaryActionHandler} + @keydown=${this.primaryActionHandler} + @contextmenu=${this.#secondaryActionHandler} + > + ${this.#faviconTemplate()} + </moz-button> + `; + } + + #unpinnedTabItemTemplate() { + return html`<a + href=${ifDefined(this.url)} + class="fxview-tab-row-main" + id="fxview-tab-row-main" + tabindex=${this.active && + this.currentActiveElementId === "fxview-tab-row-main" + ? "0" + : "-1"} + data-l10n-id=${ifDefined(this.primaryL10nId)} + data-l10n-args=${ifDefined(this.primaryL10nArgs)} + @click=${this.primaryActionHandler} + @keydown=${this.primaryActionHandler} + title=${!this.primaryL10nId ? this.url : null} + > + ${this.#faviconTemplate()} ${this.titleTemplate()} + ${when( + !this.compact, + () => html`${this.#containerIndicatorTemplate()} ${this.urlTemplate()} + ${this.dateTemplate()} ${this.timeTemplate()}` + )} + </a> + ${this.#mediaButtonTemplate()} ${this.secondaryButtonTemplate()} + ${this.tertiaryButtonTemplate()}`; + } + + render() { + return html` + ${this.stylesheets()} + <link + rel="stylesheet" + href="chrome://browser/content/firefoxview/opentabs-tab-row.css" + /> + ${when( + this.containerObj, + () => html` + <link + rel="stylesheet" + href="chrome://browser/content/usercontext/usercontext.css" + /> + ` + )} + ${when( + this.pinnedTabsGridView && this.indicators?.includes("pinned"), + this.#pinnedTabItemTemplate.bind(this), + this.#unpinnedTabItemTemplate.bind(this) + )} + `; + } +} +customElements.define("opentabs-tab-row", OpenTabsTabRow); diff --git a/browser/components/firefoxview/opentabs-tab-row.css b/browser/components/firefoxview/opentabs-tab-row.css new file mode 100644 index 0000000000..e5c00884b3 --- /dev/null +++ b/browser/components/firefoxview/opentabs-tab-row.css @@ -0,0 +1,119 @@ +/* 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/. */ + +.fxview-tab-row-favicon-wrapper { + height: 16px; + position: relative; + display: block; + + .fxview-tab-row-favicon::after, + .fxview-tab-row-button::after, + &.pinned .fxview-tab-row-pinned-media-button { + display: block; + content: ""; + background-size: 12px; + background-position: center; + background-repeat: no-repeat; + position: relative; + height: 12px; + width: 12px; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: var(--fxview-background-color-secondary); + } + + &:is(.pinnedOnNewTab, .bookmark):not(.attention) .fxview-tab-row-favicon::after { + inset-block-start: 9px; + inset-inline-end: -6px; + } + + &.pinnedOnNewTab .fxview-tab-row-favicon::after, + &.pinnedOnNewTab .fxview-tab-row-button::after { + background-image: url("chrome://browser/skin/pin-12.svg"); + } + + &.bookmark .fxview-tab-row-favicon::after, + &.bookmark .fxview-tab-row-button::after { + background-image: url("chrome://browser/skin/bookmark-12.svg"); + fill: var(--fxview-primary-action-background); + } + + &.attention .fxview-tab-row-favicon::after, + &.attention .fxview-tab-row-button::after { + background-image: radial-gradient(circle, var(--attention-dot-color), var(--attention-dot-color) 2px, transparent 2px); + height: 4px; + width: 100%; + inset-block-start: 20px; + } + + &.pinned .fxview-tab-row-pinned-media-button { + inset-block-start: -5px; + inset-inline-end: 1px; + border: var(--button-border); + border-radius: 100%; + background-color: var(--fxview-background-color-secondary); + padding: 6px; + min-width: 0; + min-height: 0; + position: absolute; + + &[muted="true"] { + background-image: url("chrome://global/skin/media/audio-muted.svg"); + } + + &[soundplaying="true"] { + background-image: url("chrome://global/skin/media/audio.svg"); + } + + &:active, + &:hover:active { + background-color: var(--button-background-color-active); + } + } +} + +.fxview-tab-row-container-indicator { + height: 16px; + width: 16px; + background-image: var(--identity-icon); + background-size: cover; + -moz-context-properties: fill; + fill: var(--identity-icon-color); +} + +.fxview-tab-row-main { + :host(.pinned) & { + padding: var(--space-small); + min-width: unset; + margin: 0; + } +} + +button.fxview-tab-row-main:hover { + & .fxview-tab-row-favicon-wrapper .fxview-tab-row-favicon::after { + stroke: var(--fxview-indicator-stroke-color-hover); + } +} + +@media (prefers-contrast) { + button.fxview-tab-row-main { + border: 1px solid ButtonText; + color: ButtonText; + } + + button.fxview-tab-row-main:enabled:hover { + border: 1px solid SelectedItem; + color: SelectedItem; + } + + button.fxview-tab-row-main:enabled:active { + color: SelectedItem; + } + + button.fxview-tab-row-main:enabled, + button.fxview-tab-row-main:enabled:hover, + button.fxview-tab-row-main:enabled:active { + background-color: ButtonFace; + } +} diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs index 8d7723e931..fb84553e26 100644 --- a/browser/components/firefoxview/opentabs.mjs +++ b/browser/components/firefoxview/opentabs.mjs @@ -17,6 +17,8 @@ import { MAX_TABS_FOR_RECENT_BROWSING, } from "./helpers.mjs"; import { ViewPage, ViewPageContent } from "./viewpage.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/opentabs-tab-list.mjs"; const lazy = {}; @@ -36,6 +38,9 @@ ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { ).getFxAccountsSingleton(); }); +const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; +const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; + /** * A collection of open tabs grouped by window. * @@ -339,7 +344,7 @@ class OpenTabsInView extends ViewPage { ></view-opentabs-card>`; } - handleEvent({ detail, target, type }) { + handleEvent({ detail, type }) { if (this.recentBrowsing && type === "fxview-search-textbox-query") { this.onSearchQuery({ detail }); return; @@ -424,7 +429,7 @@ class OpenTabsInViewCard extends ViewPageContent { static queries = { cardEl: "card-container", tabContextMenu: "view-opentabs-contextmenu", - tabList: "fxview-tab-list", + tabList: "opentabs-tab-list", }; openContextMenu(e) { @@ -565,7 +570,7 @@ class OpenTabsInViewCard extends ViewPageContent { () => html`<h3 slot="header">${this.title}</h3>` )} <div class="fxview-tab-list-container" slot="main"> - <fxview-tab-list + <opentabs-tab-list .hasPopup=${"menu"} ?compactRows=${this.classList.contains("width-limited")} @fxview-tab-list-primary-action=${this.onTabListRowClick} @@ -579,7 +584,7 @@ class OpenTabsInViewCard extends ViewPageContent { .searchQuery=${this.searchQuery} .pinnedTabsGridView=${!this.recentBrowsing} ><view-opentabs-contextmenu slot="menu"></view-opentabs-contextmenu> - </fxview-tab-list> + </opentabs-tab-list> </div> ${when( this.recentBrowsing, @@ -659,7 +664,7 @@ customElements.define("view-opentabs-card", OpenTabsInViewCard); class OpenTabsContextMenu extends MozLitElement { static properties = { devices: { type: Array }, - triggerNode: { type: Object }, + triggerNode: { hasChanged: () => true, type: Object }, }; static queries = { @@ -669,6 +674,7 @@ class OpenTabsContextMenu extends MozLitElement { constructor() { super(); this.triggerNode = null; + this.boundObserve = (...args) => this.observe(...args); this.devices = []; } @@ -680,6 +686,28 @@ class OpenTabsContextMenu extends MozLitElement { return this.ownerDocument.querySelector("view-opentabs"); } + connectedCallback() { + super.connectedCallback(); + this.fetchDevicesPromise = this.fetchDevices(); + Services.obs.addObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED); + Services.obs.addObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED); + } + + disconnectedCallback() { + super.disconnectedCallback(); + Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED); + Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED); + } + + observe(_subject, topic, _data) { + if ( + topic == TOPIC_DEVICELIST_UPDATED || + topic == TOPIC_DEVICESTATE_CHANGED + ) { + this.fetchDevicesPromise = this.fetchDevices(); + } + } + async fetchDevices() { const currentWindow = this.ownerViewPage.getWindow(); if (currentWindow?.gSync) { @@ -699,7 +727,7 @@ class OpenTabsContextMenu extends MozLitElement { return; } this.triggerNode = triggerNode; - await this.fetchDevices(); + await this.fetchDevicesPromise; await this.getUpdateComplete(); this.panelList.toggle(originalEvent); } @@ -1022,7 +1050,7 @@ function getTabListItems(tabs, isRecentBrowsing) { ? JSON.stringify({ tabTitle: tab.label }) : null, tabElement: tab, - time: tab.lastAccessed, + time: tab.lastSeenActive, title: tab.label, url, }; diff --git a/browser/components/firefoxview/recentlyclosed.mjs b/browser/components/firefoxview/recentlyclosed.mjs index 83c323256c..7efd8d09f2 100644 --- a/browser/components/firefoxview/recentlyclosed.mjs +++ b/browser/components/firefoxview/recentlyclosed.mjs @@ -65,7 +65,7 @@ class RecentlyClosedTabsInView extends ViewPage { tabList: "fxview-tab-list", }; - observe(subject, topic, data) { + observe(subject, topic) { if ( topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED || (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH && @@ -249,13 +249,22 @@ class RecentlyClosedTabsInView extends ViewPage { onDismissTab(e) { const closedId = parseInt(e.originalTarget.closedId, 10); const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10); - const sourceWindowId = e.originalTarget.souceWindowId; - if (sourceWindowId || !isNaN(sourceClosedId)) { + const sourceWindowId = e.originalTarget.sourceWindowId; + if (!isNaN(sourceClosedId)) { + // the sourceClosedId is an identifier for a now-closed window the tab + // was closed in. lazy.SessionStore.forgetClosedTabById(closedId, { sourceClosedId, + }); + } else if (sourceWindowId) { + // the sourceWindowId is an identifier for a currently-open window the tab + // was closed in. + lazy.SessionStore.forgetClosedTabById(closedId, { sourceWindowId, }); } else { + // without either identifier, SessionStore will need to walk its window collections + // to find the close tab with matching closedId lazy.SessionStore.forgetClosedTabById(closedId); } @@ -387,7 +396,6 @@ class RecentlyClosedTabsInView extends ViewPage { () => html` <fxview-tab-list - class="with-dismiss-button" slot="main" .maxTabsLength=${!this.recentBrowsing || this.showAll ? -1 diff --git a/browser/components/firefoxview/syncedtabs.mjs b/browser/components/firefoxview/syncedtabs.mjs index d64da45a30..1c65650c10 100644 --- a/browser/components/firefoxview/syncedtabs.mjs +++ b/browser/components/firefoxview/syncedtabs.mjs @@ -4,13 +4,9 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", - SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + SyncedTabsController: "resource:///modules/SyncedTabsController.sys.mjs", }); -const { SyncedTabsErrorHandler } = ChromeUtils.importESModule( - "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs" -); const { TabsSetupFlowManager } = ChromeUtils.importESModule( "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" ); @@ -24,43 +20,52 @@ import { ViewPage } from "./viewpage.mjs"; import { escapeHtmlEntities, isSearchEnabled, - searchTabList, MAX_TABS_FOR_RECENT_BROWSING, + navigateToLink, } from "./helpers.mjs"; -const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; -const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; class SyncedTabsInView extends ViewPage { + controller = new lazy.SyncedTabsController(this, { + contextMenu: true, + pairDeviceCallback: () => + Services.telemetry.recordEvent( + "firefoxview_next", + "fxa_mobile", + "sync", + null, + { + has_devices: TabsSetupFlowManager.secondaryDeviceConnected.toString(), + } + ), + signupCallback: () => + Services.telemetry.recordEvent( + "firefoxview_next", + "fxa_continue", + "sync", + null + ), + }); + constructor() { super(); this._started = false; - this.boundObserve = (...args) => this.observe(...args); - this._currentSetupStateIndex = -1; - this.errorState = null; this._id = Math.floor(Math.random() * 10e6); - this.currentSyncedTabs = []; if (this.recentBrowsing) { this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING; } else { // Setting maxTabsLength to -1 for no max this.maxTabsLength = -1; } - this.devices = []; this.fullyUpdated = false; - this.searchQuery = ""; this.showAll = false; this.cumulativeSearches = 0; + this.onSearchQuery = this.onSearchQuery.bind(this); } static properties = { ...ViewPage.properties, - errorState: { type: Number }, - currentSyncedTabs: { type: Array }, - _currentSetupStateIndex: { type: Number }, - devices: { type: Array }, - searchQuery: { type: String }, showAll: { type: Boolean }, cumulativeSearches: { type: Number }, }; @@ -72,26 +77,19 @@ class SyncedTabsInView extends ViewPage { tabLists: { all: "fxview-tab-list" }, }; - connectedCallback() { - super.connectedCallback(); - this.addEventListener("click", this); - } - start() { if (this._started) { return; } this._started = true; - Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); - Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); - - this.updateStates(); + this.controller.addSyncObservers(); + this.controller.updateStates(); this.onVisibilityChange(); if (this.recentBrowsing) { this.recentBrowsingElement.addEventListener( "fxview-search-textbox-query", - this + this.onSearchQuery ); } } @@ -103,75 +101,21 @@ class SyncedTabsInView extends ViewPage { this._started = false; TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); this.onVisibilityChange(); - - Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); - Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); + this.controller.removeSyncObservers(); if (this.recentBrowsing) { this.recentBrowsingElement.removeEventListener( "fxview-search-textbox-query", - this + this.onSearchQuery ); } } - willUpdate(changedProperties) { - if (changedProperties.has("searchQuery")) { - this.cumulativeSearches = this.searchQuery - ? this.cumulativeSearches + 1 - : 0; - } - } - disconnectedCallback() { super.disconnectedCallback(); this.stop(); } - handleEvent(event) { - if (event.type == "click" && event.target.dataset.action) { - const { ErrorType } = SyncedTabsErrorHandler; - switch (event.target.dataset.action) { - case `${ErrorType.SYNC_ERROR}`: - case `${ErrorType.NETWORK_OFFLINE}`: - case `${ErrorType.PASSWORD_LOCKED}`: { - TabsSetupFlowManager.tryToClearError(); - break; - } - case `${ErrorType.SIGNED_OUT}`: - case "sign-in": { - TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); - break; - } - case "add-device": { - TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); - break; - } - case "sync-tabs-disabled": { - TabsSetupFlowManager.syncOpenTabs(event.target); - break; - } - case `${ErrorType.SYNC_DISCONNECTED}`: { - const win = event.target.ownerGlobal; - const { switchToTabHavingURI } = - win.docShell.chromeEventHandler.ownerGlobal; - switchToTabHavingURI( - "about:preferences?action=choose-what-to-sync#sync", - true, - {} - ); - break; - } - } - } - if (event.type == "change") { - TabsSetupFlowManager.syncOpenTabs(event.target); - } - if (this.recentBrowsing && event.type === "fxview-search-textbox-query") { - this.onSearchQuery(event); - } - } - viewVisibleCallback() { this.start(); } @@ -196,90 +140,16 @@ class SyncedTabsInView extends ViewPage { this.toggleVisibilityInCardContainer(); } - async observe(subject, topic, errorState) { - if (topic == TOPIC_SETUPSTATE_CHANGED) { - this.updateStates(errorState); - } - if (topic == SYNCED_TABS_CHANGED) { - this.getSyncedTabData(); - } - } - - updateStates(errorState) { - let stateIndex = TabsSetupFlowManager.uiStateIndex; - errorState = errorState || SyncedTabsErrorHandler.getErrorType(); - - if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) { - // trigger an initial request for the synced tabs list - this.getSyncedTabData(); - } - - this._currentSetupStateIndex = stateIndex; - this.errorState = errorState; - } - - actionMappings = { - "sign-in": { - header: "firefoxview-syncedtabs-signin-header", - description: "firefoxview-syncedtabs-signin-description", - buttonLabel: "firefoxview-syncedtabs-signin-primarybutton", - }, - "add-device": { - header: "firefoxview-syncedtabs-adddevice-header", - description: "firefoxview-syncedtabs-adddevice-description", - buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton", - descriptionLink: { - name: "url", - url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync", - }, - }, - "sync-tabs-disabled": { - header: "firefoxview-syncedtabs-synctabs-header", - description: "firefoxview-syncedtabs-synctabs-description", - buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton", - }, - loading: { - header: "firefoxview-syncedtabs-loading-header", - description: "firefoxview-syncedtabs-loading-description", - }, - }; - - generateMessageCard({ error = false, action, errorState }) { - errorState = errorState || this.errorState; - let header, - description, - descriptionLink, - buttonLabel, - headerIconUrl, - mainImageUrl; - let descriptionArray; - if (error) { - let link; - ({ header, description, link, buttonLabel } = - SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState)); - action = `${errorState}`; - headerIconUrl = "chrome://global/skin/icons/info-filled.svg"; - mainImageUrl = - "chrome://browser/content/firefoxview/synced-tabs-error.svg"; - descriptionArray = [description]; - if (errorState == "password-locked") { - descriptionLink = {}; - // This is ugly, but we need to special case this link so we can - // coexist with the old view. - descriptionArray.push("firefoxview-syncedtab-password-locked-link"); - descriptionLink.name = "syncedtab-password-locked-link"; - descriptionLink.url = link.href; - } - } else { - header = this.actionMappings[action].header; - description = this.actionMappings[action].description; - buttonLabel = this.actionMappings[action].buttonLabel; - descriptionLink = this.actionMappings[action].descriptionLink; - mainImageUrl = - "chrome://browser/content/firefoxview/synced-tabs-error.svg"; - descriptionArray = [description]; - } - + generateMessageCard({ + action, + buttonLabel, + descriptionArray, + descriptionLink, + error, + header, + headerIconUrl, + mainImageUrl, + }) { return html` <fxview-empty-state headerLabel=${header} @@ -299,7 +169,7 @@ class SyncedTabsInView extends ViewPage { ?hidden=${!buttonLabel} data-l10n-id="${ifDefined(buttonLabel)}" data-action="${action}" - @click=${this.handleEvent} + @click=${e => this.controller.handleEvent(e)} aria-details="empty-container" ></button> </fxview-empty-state> @@ -307,28 +177,19 @@ class SyncedTabsInView extends ViewPage { } onOpenLink(event) { - let currentWindow = this.getWindow(); - if (currentWindow.openTrustedLinkIn) { - let where = lazy.BrowserUtils.whereToOpenLink( - event.detail.originalEvent, - false, - true - ); - if (where == "current") { - where = "tab"; + navigateToLink(event); + + Services.telemetry.recordEvent( + "firefoxview_next", + "synced_tabs", + "tabs", + null, + { + page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs", } - currentWindow.openTrustedLinkIn(event.originalTarget.url, where); - Services.telemetry.recordEvent( - "firefoxview_next", - "synced_tabs", - "tabs", - null, - { - page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs", - } - ); - } - if (this.searchQuery) { + ); + + if (this.controller.searchQuery) { const searchesHistogram = Services.telemetry.getKeyedHistogramById( "FIREFOX_VIEW_CUMULATIVE_SEARCHES" ); @@ -384,7 +245,7 @@ class SyncedTabsInView extends ViewPage { class="blackbox notabs search-results-empty" data-l10n-id="firefoxview-search-results-empty" data-l10n-args=${JSON.stringify({ - query: escapeHtmlEntities(this.searchQuery), + query: escapeHtmlEntities(this.controller.searchQuery), })} ></div> `, @@ -405,7 +266,8 @@ class SyncedTabsInView extends ViewPage { } onSearchQuery(e) { - this.searchQuery = e.detail.query; + this.controller.searchQuery = e.detail.query; + this.cumulativeSearches = e.detail.query ? this.cumulativeSearches + 1 : 0; this.showAll = false; } @@ -422,7 +284,7 @@ class SyncedTabsInView extends ViewPage { secondaryActionClass="options-button" hasPopup="menu" .tabItems=${ifDefined(tabItems)} - .searchQuery=${this.searchQuery} + .searchQuery=${this.controller.searchQuery} maxTabsLength=${this.showAll ? -1 : this.maxTabsLength} @fxview-tab-list-primary-action=${this.onOpenLink} @fxview-tab-list-secondary-action=${this.onContextMenu} @@ -434,33 +296,9 @@ class SyncedTabsInView extends ViewPage { generateTabList() { let renderArray = []; - let renderInfo = {}; - for (let tab of this.currentSyncedTabs) { - if (!(tab.client in renderInfo)) { - renderInfo[tab.client] = { - name: tab.device, - deviceType: tab.deviceType, - tabs: [], - }; - } - renderInfo[tab.client].tabs.push(tab); - } - - // Add devices without tabs - for (let device of this.devices) { - if (!(device.id in renderInfo)) { - renderInfo[device.id] = { - name: device.name, - deviceType: device.clientType, - tabs: [], - }; - } - } - + let renderInfo = this.controller.getRenderInfo(); for (let id in renderInfo) { - let tabItems = this.searchQuery - ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id].tabs)) - : this.getTabItems(renderInfo[id].tabs); + let tabItems = renderInfo[id].tabItems; if (tabItems.length) { const template = this.recentBrowsing ? this.deviceTemplate( @@ -509,7 +347,7 @@ class SyncedTabsInView extends ViewPage { isShowAllLinkVisible(tabItems) { return ( this.recentBrowsing && - this.searchQuery && + this.controller.searchQuery && tabItems.length > this.maxTabsLength && !this.showAll ); @@ -536,35 +374,10 @@ class SyncedTabsInView extends ViewPage { } generateCardContent() { - switch (this._currentSetupStateIndex) { - case 0 /* error-state */: - if (this.errorState) { - return this.generateMessageCard({ error: true }); - } - return this.generateMessageCard({ action: "loading" }); - case 1 /* not-signed-in */: - if (Services.prefs.prefHasUserValue("services.sync.lastversion")) { - // If this pref is set, the user has signed out of sync. - // This path is also taken if we are disconnected from sync. See bug 1784055 - return this.generateMessageCard({ - error: true, - errorState: "signed-out", - }); - } - return this.generateMessageCard({ action: "sign-in" }); - case 2 /* connect-secondary-device*/: - return this.generateMessageCard({ action: "add-device" }); - case 3 /* disabled-tab-sync */: - return this.generateMessageCard({ action: "sync-tabs-disabled" }); - case 4 /* synced-tabs-loaded*/: - // There seems to be an edge case where sync says everything worked - // fine but we have no devices. - if (!this.devices.length) { - return this.generateMessageCard({ action: "add-device" }); - } - return this.generateTabList(); - } - return html``; + const cardProperties = this.controller.getMessageCard(); + return cardProperties + ? this.generateMessageCard(cardProperties) + : this.generateTabList(); } render() { @@ -589,7 +402,7 @@ class SyncedTabsInView extends ViewPage { data-l10n-id="firefoxview-synced-tabs-header" ></h2> ${when( - isSearchEnabled() || this._currentSetupStateIndex === 4, + isSearchEnabled() || this.controller.currentSetupStateIndex === 4, () => html`<div class="syncedtabs-header"> ${when( isSearchEnabled(), @@ -606,12 +419,12 @@ class SyncedTabsInView extends ViewPage { </div>` )} ${when( - this._currentSetupStateIndex === 4, + this.controller.currentSetupStateIndex === 4, () => html` <button class="small-button" data-action="add-device" - @click=${this.handleEvent} + @click=${e => this.controller.handleEvent(e)} > <img class="icon" @@ -635,9 +448,9 @@ class SyncedTabsInView extends ViewPage { html`<card-container preserveCollapseState shortPageName="syncedtabs" - ?showViewAll=${this._currentSetupStateIndex == 4 && - this.currentSyncedTabs.length} - ?isEmptyState=${!this.currentSyncedTabs.length} + ?showViewAll=${this.controller.currentSetupStateIndex == 4 && + this.controller.currentSyncedTabs.length} + ?isEmptyState=${!this.controller.currentSyncedTabs.length} > > <h3 @@ -656,71 +469,9 @@ class SyncedTabsInView extends ViewPage { return renderArray; } - async onReload() { - await TabsSetupFlowManager.syncOnPageReload(); - } - - getTabItems(tabs) { - tabs = tabs || this.tabs; - return tabs?.map(tab => ({ - icon: tab.icon, - title: tab.title, - time: tab.lastUsed * 1000, - url: tab.url, - primaryL10nId: "firefoxview-tabs-list-tab-button", - primaryL10nArgs: JSON.stringify({ targetURI: tab.url }), - secondaryL10nId: "fxviewtabrow-options-menu-button", - secondaryL10nArgs: JSON.stringify({ tabTitle: tab.title }), - })); - } - - updateTabsList(syncedTabs) { - if (!syncedTabs.length) { - this.currentSyncedTabs = syncedTabs; - this.sendTabTelemetry(0); - } - - const tabsToRender = syncedTabs; - - // Return early if new tabs are the same as previous ones - if ( - JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs) - ) { - return; - } - - this.currentSyncedTabs = tabsToRender; - // Record the full tab count - this.sendTabTelemetry(syncedTabs.length); - } - - async getSyncedTabData() { - this.devices = await lazy.SyncedTabs.getTabClients(); - let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 50, { - removeAllDupes: false, - removeDeviceDupes: true, - }); - - this.updateTabsList(tabs); - } - updated() { this.fullyUpdated = true; this.toggleVisibilityInCardContainer(); } - - sendTabTelemetry(numTabs) { - /* - Services.telemetry.recordEvent( - "firefoxview_next", - "synced_tabs", - "tabs", - null, - { - count: numTabs.toString(), - } - ); -*/ - } } customElements.define("view-syncedtabs", SyncedTabsInView); diff --git a/browser/components/firefoxview/tests/browser/browser.toml b/browser/components/firefoxview/tests/browser/browser.toml index 9f9c1c0176..db8b2ea25c 100644 --- a/browser/components/firefoxview/tests/browser/browser.toml +++ b/browser/components/firefoxview/tests/browser/browser.toml @@ -27,6 +27,8 @@ skip-if = ["true"] # Bug 1869605 and # Bug 1870296 ["browser_firefoxview.js"] +["browser_firefoxview_dragDrop_pinned_tab.js"] + ["browser_firefoxview_general_telemetry.js"] ["browser_firefoxview_navigation.js"] @@ -51,17 +53,15 @@ skip-if = ["true"] # Bug 1851453 ["browser_opentabs_firefoxview.js"] ["browser_opentabs_more.js"] -fail-if = ["a11y_checks"] # Bugs 1858041, 1854625, and 1872174 clicked Show all link is not accessible because it is "hidden" when clicked skip-if = ["verify"] # Bug 1886017 ["browser_opentabs_pinned_tabs.js"] ["browser_opentabs_recency.js"] skip-if = [ - "os == 'win'", - "os == 'mac' && verify", + "os == 'mac'", "os == 'linux'" -] # macos times out, see bug 1857293, skipped for windows, see bug 1858460, Bug 1875877 - frequent fails on linux. +] # macos times out, see bug 1857293, Bug 1875877 - frequent fails on linux. ["browser_opentabs_search.js"] fail-if = ["a11y_checks"] # Bug 1850591 clicked moz-page-nav-button button is not focusable diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_dragDrop_pinned_tab.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_dragDrop_pinned_tab.js new file mode 100644 index 0000000000..dd30d53030 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_dragDrop_pinned_tab.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function dragAndDrop( + tab1, + tab2, + initialWindow = window, + destWindow = window, + afterTab = true, + context +) { + let rect = tab2.getBoundingClientRect(); + let event = { + ctrlKey: false, + altKey: false, + clientX: rect.left + rect.width / 2 + 10 * (afterTab ? 1 : -1), + clientY: rect.top + rect.height / 2, + }; + + if (destWindow != initialWindow) { + // Make sure that both tab1 and tab2 are visible + initialWindow.focus(); + initialWindow.moveTo(rect.left, rect.top + rect.height * 3); + } + + EventUtils.synthesizeDrop( + tab1, + tab2, + null, + "move", + initialWindow, + destWindow, + event + ); + + // Ensure dnd suppression is cleared. + EventUtils.synthesizeMouseAtCenter(tab2, { type: "mouseup" }, context); +} + +add_task(async function () { + await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[0]); + await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win1 = browser.ownerGlobal; + await navigateToViewAndWait(document, "opentabs"); + + let openTabs = document.querySelector("view-opentabs[name=opentabs]"); + await openTabs.updateComplete; + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList.rowEls.length + ); + await openTabs.openTabsTarget.readyWindowsPromise; + let card = openTabs.viewCards[0]; + let tabRows = card.tabList.rowEls; + let tabChangeRaised; + + // Pin first two tabs + for (var i = 0; i < 2; i++) { + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + let currentTabEl = tabRows[i]; + let currentTab = currentTabEl.tabElement; + info(`Pinning tab ${i + 1} with label: ${currentTab.label}`); + win1.gBrowser.pinTab(currentTab); + await tabChangeRaised; + await openTabs.updateComplete; + tabRows = card.tabList.rowEls; + currentTabEl = tabRows[i]; + + await TestUtils.waitForCondition( + () => currentTabEl.indicators.includes("pinned"), + `Tab ${i + 1} is pinned.` + ); + } + + info(`First two tabs are pinned.`); + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + await openTabs.updateComplete; + await TestUtils.waitForCondition( + () => openTabs.viewCards.length === 2, + "Two windows are shown for Open Tabs in in Fx View." + ); + + let pinnedTab = win1.gBrowser.visibleTabs[0]; + let newWindowTab = win2.gBrowser.visibleTabs[0]; + + dragAndDrop(newWindowTab, pinnedTab, win2, win1, true, content); + + await switchToFxViewTab(); + await openTabs.updateComplete; + await TestUtils.waitForCondition( + () => openTabs.viewCards.length === 1, + "One window is shown for Open Tabs in in Fx View." + ); + }); + cleanupTabs(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js index e61b48b472..52dfce962d 100644 --- a/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js @@ -191,42 +191,6 @@ async function checkFxRenderCalls(browser, elements, selectedView) { sandbox.restore(); } -function dragAndDrop( - tab1, - tab2, - initialWindow = window, - destWindow = window, - afterTab = true, - context -) { - let rect = tab2.getBoundingClientRect(); - let event = { - ctrlKey: false, - altKey: false, - clientX: rect.left + rect.width / 2 + 10 * (afterTab ? 1 : -1), - clientY: rect.top + rect.height / 2, - }; - - if (destWindow != initialWindow) { - // Make sure that both tab1 and tab2 are visible - initialWindow.focus(); - initialWindow.moveTo(rect.left, rect.top + rect.height * 3); - } - - EventUtils.synthesizeDrop( - tab1, - tab2, - null, - "move", - initialWindow, - destWindow, - event - ); - - // Ensure dnd suppression is cleared. - EventUtils.synthesizeMouseAtCenter(tab2, { type: "mouseup" }, context); -} - add_task(async function test_recentbrowsing() { await setupOpenAndClosedTabs(); @@ -438,66 +402,3 @@ add_task(async function test_recentlyclosed() { }); await BrowserTestUtils.removeTab(TestTabs.tab2); }); - -add_task(async function test_drag_drop_pinned_tab() { - await setupOpenAndClosedTabs(); - await withFirefoxView({}, async browser => { - const { document } = browser.contentWindow; - let win1 = browser.ownerGlobal; - await navigateToViewAndWait(document, "opentabs"); - - let openTabs = document.querySelector("view-opentabs[name=opentabs]"); - await openTabs.updateComplete; - await TestUtils.waitForCondition( - () => openTabs.viewCards[0].tabList.rowEls.length - ); - await openTabs.openTabsTarget.readyWindowsPromise; - let card = openTabs.viewCards[0]; - let tabRows = card.tabList.rowEls; - let tabChangeRaised; - - // Pin first two tabs - for (var i = 0; i < 2; i++) { - tabChangeRaised = BrowserTestUtils.waitForEvent( - NonPrivateTabs, - "TabChange" - ); - let currentTabEl = tabRows[i]; - let currentTab = currentTabEl.tabElement; - info(`Pinning tab ${i + 1} with label: ${currentTab.label}`); - win1.gBrowser.pinTab(currentTab); - await tabChangeRaised; - await openTabs.updateComplete; - tabRows = card.tabList.rowEls; - currentTabEl = tabRows[i]; - - await TestUtils.waitForCondition( - () => currentTabEl.indicators.includes("pinned"), - `Tab ${i + 1} is pinned.` - ); - } - - info(`First two tabs are pinned.`); - - let win2 = await BrowserTestUtils.openNewBrowserWindow(); - - await openTabs.updateComplete; - await TestUtils.waitForCondition( - () => openTabs.viewCards.length === 2, - "Two windows are shown for Open Tabs in in Fx View." - ); - - let pinnedTab = win1.gBrowser.visibleTabs[0]; - let newWindowTab = win2.gBrowser.visibleTabs[0]; - - dragAndDrop(newWindowTab, pinnedTab, win2, win1, true, content); - - await switchToFxViewTab(); - await openTabs.updateComplete; - await TestUtils.waitForCondition( - () => openTabs.viewCards.length === 1, - "One window is shown for Open Tabs in in Fx View." - ); - }); - cleanupTabs(); -}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js index c76a11d3ad..e1aa58ae49 100644 --- a/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js @@ -537,7 +537,7 @@ add_task(async function test_cumulative_searches_history_telemetry() { () => history.fullyUpdated && history?.lists[0].rowEls?.length === 1 && - history?.searchQuery, + history?.controller?.searchQuery, "Expected search results are not shown yet." ); @@ -605,7 +605,8 @@ add_task(async function test_cumulative_searches_syncedtabs_telemetry() { ); await TestUtils.waitForCondition( () => - syncedTabs.tabLists[0].rowEls.length === 1 && syncedTabs?.searchQuery, + syncedTabs.tabLists[0].rowEls.length === 1 && + syncedTabs.controller.searchQuery, "Expected search results are not shown yet." ); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js index 037729ea7d..b556649d52 100644 --- a/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js @@ -78,7 +78,7 @@ add_task(async function aria_attributes() { "true", 'Firefox View button should have `aria-pressed="true"` upon selecting it' ); - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); is( win.FirefoxViewHandler.button.getAttribute("aria-pressed"), "false", @@ -118,8 +118,8 @@ add_task(async function homepage_new_tab() { win.gBrowser.tabContainer, "TabOpen" ); - win.BrowserHome(); - info("Waiting for BrowserHome() to open a new tab"); + win.BrowserCommands.home(); + info("Waiting for BrowserCommands.home() to open a new tab"); await newTabOpened; assertFirefoxViewTab(win); ok( diff --git a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js index c4c096acff..847ce4d9fd 100644 --- a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js @@ -58,14 +58,14 @@ function isElInViewport(element) { async function historyComponentReady(historyComponent, expectedHistoryItems) { await TestUtils.waitForCondition( () => - [...historyComponent.allHistoryItems.values()].reduce( + [...historyComponent.controller.allHistoryItems.values()].reduce( (acc, { length }) => acc + length, 0 ) === expectedHistoryItems, "History component ready" ); - let expected = historyComponent.historyMapByDate.length; + let expected = historyComponent.controller.historyMapByDate.length; let actual = historyComponent.cards.length; is(expected, actual, `Total number of cards should be ${expected}`); @@ -242,7 +242,8 @@ add_task(async function test_list_ordering() { await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); await sortHistoryTelemetry(sortHistoryEvent); - let expectedNumOfCards = historyComponent.historyMapBySite.length; + let expectedNumOfCards = + historyComponent.controller.historyMapBySite.length; info(`Total number of cards should be ${expectedNumOfCards}`); await BrowserTestUtils.waitForMutationCondition( @@ -345,7 +346,7 @@ add_task(async function test_empty_states() { "Import history banner is shown" ); let importHistoryCloseButton = - historyComponent.cards[0].querySelector("button.close"); + historyComponent.cards[0].querySelector("moz-button.close"); importHistoryCloseButton.click(); await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); ok( @@ -484,7 +485,7 @@ add_task(async function test_search_history() { { childList: true, subtree: true }, () => historyComponent.cards.length === - historyComponent.historyMapByDate.length + historyComponent.controller.historyMapByDate.length ); searchTextbox.blur(); @@ -513,7 +514,7 @@ add_task(async function test_search_history() { { childList: true, subtree: true }, () => historyComponent.cards.length === - historyComponent.historyMapByDate.length + historyComponent.controller.historyMapByDate.length ); }); }); @@ -528,7 +529,7 @@ add_task(async function test_persist_collapse_card_after_view_change() { historyComponent.profileAge = 8; await TestUtils.waitForCondition( () => - [...historyComponent.allHistoryItems.values()].reduce( + [...historyComponent.controller.allHistoryItems.values()].reduce( (acc, { length }) => acc + length, 0 ) === 4 diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js index d4de3ae5a9..5fdcf89d70 100644 --- a/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js @@ -203,13 +203,15 @@ add_task(async function open_tab_new_window() { const cards = getOpenTabsCards(openTabs); const originalWinRows = await getTabRowsForCard(cards[1]); const [row] = originalWinRows; + + // We hide date/time and URL columns in tab rows when there are multiple window cards for spacial reasons ok( - row.shadowRoot.getElementById("fxview-tab-row-url").hidden, - "The URL is hidden, since we have two windows." + !row.shadowRoot.getElementById("fxview-tab-row-url"), + "The URL span element isn't found within the tab row as expected, since we have two open windows." ); ok( - row.shadowRoot.getElementById("fxview-tab-row-date").hidden, - "The date is hidden, since we have two windows." + !row.shadowRoot.getElementById("fxview-tab-row-date"), + "The date span element isn't found within the tab row as expected, since we have two open windows." ); info("Select a tab from the original window."); tabChangeRaised = BrowserTestUtils.waitForEvent( diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js index 955c2363d7..2c415e7aa2 100644 --- a/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js @@ -131,7 +131,7 @@ async function moreMenuSetup() { } add_task(async function test_close_open_tab() { - await withFirefoxView({}, async browser => { + await withFirefoxView({}, async () => { const [cards, rows] = await moreMenuSetup(); const firstTab = rows[0]; const tertiaryButtonEl = firstTab.tertiaryButtonEl; @@ -321,7 +321,7 @@ add_task(async function test_send_device_submenu() { .stub(gSync, "getSendTabTargets") .callsFake(() => fxaDevicesWithCommands); - await withFirefoxView({}, async browser => { + await withFirefoxView({}, async () => { // TEST_URL1 is our only tab, left over from previous test Assert.deepEqual( getVisibleTabURLs(), diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js index ee3f9981e1..fc10ef2eb0 100644 --- a/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js @@ -2,23 +2,30 @@ * http://creativecommons.org/publicdomain/zero/1.0/ */ /* - This test checks the recent-browsing view of open tabs in about:firefoxview next + This test checks that the recent-browsing view of open tabs in about:firefoxview presents the correct tab data in the correct order. */ +SimpleTest.requestCompleteLog(); + +const { ObjectUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ObjectUtils.sys.mjs" +); +let origBrowserState; const tabURL1 = "data:,Tab1"; const tabURL2 = "data:,Tab2"; const tabURL3 = "data:,Tab3"; const tabURL4 = "data:,Tab4"; -let gInitialTab; -let gInitialTabURL; - add_setup(function () { - gInitialTab = gBrowser.selectedTab; - gInitialTabURL = tabUrl(gInitialTab); + origBrowserState = SessionStore.getBrowserState(); }); +async function cleanup() { + await switchToWindow(window); + await SessionStoreTestUtils.promiseBrowserState(origBrowserState); +} + function tabUrl(tab) { return tab.linkedBrowser.currentURI?.spec; } @@ -37,6 +44,12 @@ async function minimizeWindow(win) { ok(win.document.hidden, "Top level window should be hidden"); } +function getAllSelectedTabURLs() { + return BrowserWindowTracker.orderedWindows.map(win => + tabUrl(win.gBrowser.selectedTab) + ); +} + async function restoreWindow(win) { ok(win.document.hidden, "Top level window should be hidden"); let promiseSizeModeChange = BrowserTestUtils.waitForEvent( @@ -93,86 +106,91 @@ async function restoreWindow(win) { ok(!win.document.hidden, "Top level window should be visible"); } -async function prepareOpenTabs(urls, win = window) { - const reusableTabURLs = ["about:newtab", "about:blank"]; - const gBrowser = win.gBrowser; - - for (let url of urls) { - if ( - gBrowser.visibleTabs.length == 1 && - reusableTabURLs.includes(gBrowser.selectedBrowser.currentURI.spec) - ) { - // we'll load into this tab rather than opening a new one - info( - `Loading ${url} into blank tab: ${gBrowser.selectedBrowser.currentURI.spec}` - ); - BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); - await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, null, url); - } else { - info(`Loading ${url} into new tab`); - await BrowserTestUtils.openNewForegroundTab(gBrowser, url); - } - await new Promise(res => win.requestAnimationFrame(res)); +async function prepareOpenWindowsAndTabs(windowsData) { + // windowsData selected tab URL should be unique so we can map tab URL to window + const browserState = { + windows: windowsData.map((winData, index) => { + const tabs = winData.tabs.map(url => ({ + entries: [{ url, triggeringPrincipal_base64 }], + })); + return { + tabs, + selected: winData.selectedIndex + 1, + zIndex: index + 1, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState(browserState); + await NonPrivateTabs.readyWindowsPromise; + const selectedTabURLOrder = browserState.windows.map(winData => { + return winData.tabs[winData.selected - 1].entries[0].url; + }); + const windowByTabURL = new Map(); + for (let win of BrowserWindowTracker.orderedWindows) { + windowByTabURL.set(tabUrl(win.gBrowser.selectedTab), win); } - Assert.equal( - gBrowser.visibleTabs.length, - urls.length, - `Prepared ${urls.length} tabs as expected` - ); - Assert.equal( - tabUrl(gBrowser.selectedTab), - urls[urls.length - 1], - "The selectedTab is the last of the URLs given as expected" + is( + windowByTabURL.size, + windowsData.length, + "The tab URL to window mapping includes an entry for each window" ); -} - -async function cleanup(...windowsToClose) { - await Promise.all( - windowsToClose.map(win => BrowserTestUtils.closeWindow(win)) + info( + `After promiseBrowserState, selected tab order is: ${Array.from( + windowByTabURL.keys() + )}` ); - while (gBrowser.visibleTabs.length > 1) { - await SessionStoreTestUtils.closeTab(gBrowser.tabs.at(-1)); - } - if (gBrowser.selectedBrowser.currentURI.spec !== gInitialTabURL) { - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - gInitialTabURL - ); - await BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - null, - gInitialTabURL - ); + // Make any corrections to the window order by selecting each in reverse order + for (let url of selectedTabURLOrder.toReversed()) { + await switchToWindow(windowByTabURL.get(url)); } + // Verify windows are in the expected order + Assert.deepEqual( + getAllSelectedTabURLs(), + selectedTabURLOrder, + "The windows and their selected tabs are in the expected order" + ); + Assert.deepEqual( + BrowserWindowTracker.orderedWindows.map(win => + win.gBrowser.visibleTabs.map(tab => tabUrl(tab)) + ), + windowsData.map(winData => winData.tabs), + "We opened all the tabs in each window" + ); } -function getOpenTabsComponent(browser) { +function getRecentOpenTabsComponent(browser) { return browser.contentDocument.querySelector( "view-recentbrowsing view-opentabs" ); } -async function checkTabList(browser, expected) { - const tabsView = getOpenTabsComponent(browser); +async function checkRecentTabList(browser, expected) { + const tabsView = getRecentOpenTabsComponent(browser); const [openTabsCard] = getOpenTabsCards(tabsView); await openTabsCard.updateComplete; const tabListRows = await getTabRowsForCard(openTabsCard); Assert.ok(tabListRows, "Found the tab list element"); let actual = Array.from(tabListRows).map(row => row.url); - Assert.deepEqual( - actual, - expected, - "Tab list has items with URLs in the expected order" + await BrowserTestUtils.waitForCondition( + () => ObjectUtils.deepEqual(actual, expected), + "Waiting for tab list to hvae items with URLs in the expected order" ); } add_task(async function test_single_window_tabs() { - await prepareOpenTabs([tabURL1, tabURL2]); + const testData = [ + { + tabs: [tabURL1, tabURL2], + selectedIndex: 1, // the 2nd tab should be selected + }, + ]; + await prepareOpenWindowsAndTabs(testData); + await openFirefoxViewTab(window).then(async viewTab => { const browser = viewTab.linkedBrowser; - await checkTabList(browser, [tabURL2, tabURL1]); + await checkRecentTabList(browser, [tabURL2, tabURL1]); // switch to the first tab let promiseHidden = BrowserTestUtils.waitForEvent( @@ -192,25 +210,62 @@ add_task(async function test_single_window_tabs() { // and check the results in the open tabs section of Recent Browsing await openFirefoxViewTab(window).then(async viewTab => { const browser = viewTab.linkedBrowser; - await checkTabList(browser, [tabURL1, tabURL2]); + await checkRecentTabList(browser, [tabURL1, tabURL2]); }); await cleanup(); }); add_task(async function test_multiple_window_tabs() { const fxViewURL = getFirefoxViewURL(); - const win1 = window; + const testData = [ + { + // this window should be active after restore + tabs: [tabURL1, tabURL2], + selectedIndex: 0, // tabURL1 should be selected + }, + { + tabs: [tabURL3, tabURL4], + selectedIndex: 0, // tabURL3 should be selected + }, + ]; + await prepareOpenWindowsAndTabs(testData); + + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL1, tabURL3], + "The windows and their selected tabs are in the expected order" + ); let tabChangeRaised; - await prepareOpenTabs([tabURL1, tabURL2]); - const win2 = await BrowserTestUtils.openNewBrowserWindow(); - await prepareOpenTabs([tabURL3, tabURL4], win2); + const [win1, win2] = BrowserWindowTracker.orderedWindows; + + info(`Switch to window 1's 2nd tab: ${tabUrl(win1.gBrowser.visibleTabs[1])}`); + await BrowserTestUtils.switchTab(gBrowser, win1.gBrowser.visibleTabs[1]); + await switchToWindow(win2); + + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL3, tabURL2], + `Window 2 has selected the ${tabURL3} tab, window 1 has ${tabURL2}` + ); + info(`Switch to window 2's 2nd tab: ${tabUrl(win2.gBrowser.visibleTabs[1])}`); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await BrowserTestUtils.switchTab(win2.gBrowser, win2.gBrowser.visibleTabs[1]); + await tabChangeRaised; + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL4, tabURL2], + `window 2 has selected the ${tabURL4} tab, ${tabURL2} remains selected in window 1` + ); // to avoid confusing the results by activating different windows, // check fxview in the current window - which is win2 info("Switching to fxview tab in win2"); await openFirefoxViewTab(win2).then(async viewTab => { const browser = viewTab.linkedBrowser; - await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]); + await checkRecentTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]); Assert.equal( tabUrl(win2.gBrowser.selectedTab), @@ -218,7 +273,7 @@ add_task(async function test_multiple_window_tabs() { `The selected tab in window 2 is ${fxViewURL}` ); - info("Switching to first tab (tab3) in win2"); + info("Switching to first tab in win2"); tabChangeRaised = BrowserTestUtils.waitForEvent( NonPrivateTabs, "TabRecencyChange" @@ -231,20 +286,20 @@ add_task(async function test_multiple_window_tabs() { win2.gBrowser, win2.gBrowser.visibleTabs[0] ); - Assert.equal( - tabUrl(win2.gBrowser.selectedTab), - tabURL3, - `The selected tab in window 2 is ${tabURL3}` - ); await tabChangeRaised; await promiseHidden; + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL3, tabURL2], + `window 2 has switched to ${tabURL3}, ${tabURL2} remains selected in window 1` + ); }); info("Opening fxview in win2 to confirm tab3 is most recent"); await openFirefoxViewTab(win2).then(async viewTab => { const browser = viewTab.linkedBrowser; info("Check result of selecting 1ist tab in window 2"); - await checkTabList(browser, [tabURL3, tabURL4, tabURL2, tabURL1]); + await checkRecentTabList(browser, [tabURL3, tabURL4, tabURL2, tabURL1]); }); info("Focusing win1, where tab2 should be selected"); @@ -254,10 +309,10 @@ add_task(async function test_multiple_window_tabs() { ); await switchToWindow(win1); await tabChangeRaised; - Assert.equal( - tabUrl(win1.gBrowser.selectedTab), - tabURL2, - `The selected tab in window 1 is ${tabURL2}` + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL2, fxViewURL], + `The selected tab in window 1 is ${tabURL2}, ${fxViewURL} remains selected in window 2` ); info("Opening fxview in win1 to confirm tab2 is most recent"); @@ -266,7 +321,7 @@ add_task(async function test_multiple_window_tabs() { info( "In fxview, check result of activating window 1, where tab 2 is selected" ); - await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]); + await checkRecentTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]); let promiseHidden = BrowserTestUtils.waitForEvent( browser.contentDocument, @@ -284,45 +339,50 @@ add_task(async function test_multiple_window_tabs() { await promiseHidden; await tabChangeRaised; }); + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL1, fxViewURL], + `The selected tab in window 1 is ${tabURL1}, ${fxViewURL} remains selected in window 2` + ); // check result in the fxview in the 1st window info("Opening fxview in win1 to confirm tab1 is most recent"); await openFirefoxViewTab(win1).then(async viewTab => { const browser = viewTab.linkedBrowser; info("Check result of selecting 1st tab in win1"); - await checkTabList(browser, [tabURL1, tabURL2, tabURL3, tabURL4]); + await checkRecentTabList(browser, [tabURL1, tabURL2, tabURL3, tabURL4]); }); - await cleanup(win2); + await cleanup(); }); add_task(async function test_windows_activation() { - const win1 = window; - await prepareOpenTabs([tabURL1], win1); - let fxViewTab; - let tabChangeRaised; - info("switch to firefox-view and leave it selected"); - await openFirefoxViewTab(win1).then(tab => (fxViewTab = tab)); + // use Session restore to batch-open windows and tabs + const testData = [ + { + // this window should be active after restore + tabs: [tabURL1], + selectedIndex: 0, // tabURL1 should be selected + }, + { + tabs: [tabURL2], + selectedIndex: 0, // tabURL2 should be selected + }, + { + tabs: [tabURL3], + selectedIndex: 0, // tabURL3 should be selected + }, + ]; + await prepareOpenWindowsAndTabs(testData); - const win2 = await BrowserTestUtils.openNewBrowserWindow(); - await switchToWindow(win2); - await prepareOpenTabs([tabURL2], win2); - - const win3 = await BrowserTestUtils.openNewBrowserWindow(); - await switchToWindow(win3); - await prepareOpenTabs([tabURL3], win3); - - tabChangeRaised = BrowserTestUtils.waitForEvent( - NonPrivateTabs, - "TabRecencyChange" - ); - info("Switching back to win 1"); - await switchToWindow(win1); - info("Waiting for tabChangeRaised to resolve"); - await tabChangeRaised; + let tabChangeRaised; + const [win1, win2] = BrowserWindowTracker.orderedWindows; - const browser = fxViewTab.linkedBrowser; - await checkTabList(browser, [tabURL3, tabURL2, tabURL1]); + info("switch to firefox-view and leave it selected"); + await openFirefoxViewTab(win1).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await checkRecentTabList(browser, [tabURL1, tabURL2, tabURL3]); + }); info("switch to win2 and confirm its selected tab becomes most recent"); tabChangeRaised = BrowserTestUtils.waitForEvent( @@ -331,24 +391,52 @@ add_task(async function test_windows_activation() { ); await switchToWindow(win2); await tabChangeRaised; - await checkTabList(browser, [tabURL2, tabURL3, tabURL1]); - await cleanup(win2, win3); + await openFirefoxViewTab(win1).then(async viewTab => { + await checkRecentTabList(viewTab.linkedBrowser, [ + tabURL2, + tabURL1, + tabURL3, + ]); + }); + await cleanup(); }); add_task(async function test_minimize_restore_windows() { - const win1 = window; - let tabChangeRaised; - await prepareOpenTabs([tabURL1, tabURL2]); - const win2 = await BrowserTestUtils.openNewBrowserWindow(); - await prepareOpenTabs([tabURL3, tabURL4], win2); - await NonPrivateTabs.readyWindowsPromise; + const fxViewURL = getFirefoxViewURL(); + const testData = [ + { + // this window should be active after restore + tabs: [tabURL1, tabURL2], + selectedIndex: 1, // tabURL2 should be selected + }, + { + tabs: [tabURL3, tabURL4], + selectedIndex: 0, // tabURL3 should be selected + }, + ]; + await prepareOpenWindowsAndTabs(testData); + const [win1, win2] = BrowserWindowTracker.orderedWindows; + + // switch to the last (tabURL4) tab in window 2 + await switchToWindow(win2); + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await BrowserTestUtils.switchTab(win2.gBrowser, win2.gBrowser.visibleTabs[1]); + await tabChangeRaised; + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL4, tabURL2], + "The windows and their selected tabs are in the expected order" + ); // to avoid confusing the results by activating different windows, // check fxview in the current window - which is win2 info("Opening fxview in win2 to confirm tab4 is most recent"); await openFirefoxViewTab(win2).then(async viewTab => { const browser = viewTab.linkedBrowser; - await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]); + await checkRecentTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]); let promiseHidden = BrowserTestUtils.waitForEvent( browser.contentDocument, @@ -366,6 +454,11 @@ add_task(async function test_minimize_restore_windows() { await promiseHidden; await tabChangeRaised; }); + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL3, tabURL2], + `Window 2 has ${tabURL3} selected, window 1 remains at ${tabURL2}` + ); // then minimize the window, focusing the 1st window info("Minimizing win2, leaving tab 3 selected"); @@ -378,32 +471,41 @@ add_task(async function test_minimize_restore_windows() { await switchToWindow(win1); await tabChangeRaised; - Assert.equal( - tabUrl(win1.gBrowser.selectedTab), - tabURL2, - `The selected tab in window 1 is ${tabURL2}` + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL2, tabURL3], + `Window 1 has ${tabURL2} selected, window 2 remains at ${tabURL3}` ); info("Opening fxview in win1 to confirm tab2 is most recent"); await openFirefoxViewTab(win1).then(async viewTab => { const browser = viewTab.linkedBrowser; - await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]); + await checkRecentTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]); info( "Restoring win2 and focusing it - which should make its selected tab most recent" ); tabChangeRaised = BrowserTestUtils.waitForEvent( NonPrivateTabs, - "TabRecencyChange" + "TabRecencyChange", + false, + event => event.detail.sourceEvents?.includes("activate") ); await restoreWindow(win2); await switchToWindow(win2); + // make sure we wait for the activate event from OpenTabs. await tabChangeRaised; + Assert.deepEqual( + getAllSelectedTabURLs(), + [tabURL3, fxViewURL], + `Window 2 was restored and has ${tabURL3} selected, window 1 remains at ${fxViewURL}` + ); + info( "Checking tab order in fxview in win1, to confirm tab3 is most recent" ); - await checkTabList(browser, [tabURL3, tabURL2, tabURL4, tabURL1]); + await checkRecentTabList(browser, [tabURL3, tabURL2, tabURL4, tabURL1]); }); - - await cleanup(win2); + info("test done, waiting for cleanup"); + await cleanup(); }); diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js index 78fab976ed..4403a8e36a 100644 --- a/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js @@ -94,12 +94,16 @@ add_task(async function test_container_indicator() { await TestUtils.waitForCondition( () => Array.from(openTabs.viewCards[0].tabList.rowEls).some(rowEl => { - containerTabElem = rowEl; - return rowEl.containerObj; + let hasContainerObj; + if (rowEl.containerObj?.icon) { + containerTabElem = rowEl; + hasContainerObj = rowEl.containerObj; + } + + return hasContainerObj; }), "The container tab element isn't marked in Fx View." ); - ok( containerTabElem.shadowRoot .querySelector(".fxview-tab-row-container-indicator") diff --git a/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js index fcfcf20562..85879667bb 100644 --- a/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js @@ -372,6 +372,12 @@ add_task(async function test_dismiss_tab() { info("calling dismiss_tab on the top, most-recently closed tab"); let closedTabItem = listItems[0]; + // the most recently closed tab was in window 3 which got closed + // so we expect a sourceClosedId on the item element + ok( + !isNaN(closedTabItem.sourceClosedId), + "Item has a sourceClosedId property" + ); // dismiss the first tab and verify the list is correctly updated await dismiss_tab(closedTabItem); @@ -390,6 +396,12 @@ add_task(async function test_dismiss_tab() { // dismiss the last tab and verify the list is correctly updated closedTabItem = listItems[listItems.length - 1]; + ok( + isNaN(closedTabItem.sourceClosedId), + "Item does not have a sourceClosedId property" + ); + ok(closedTabItem.sourceWindowId, "Item has a sourceWindowId property"); + await dismiss_tab(closedTabItem); await listElem.getUpdateComplete; diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js index 86e4d9cdee..a644b39fc6 100644 --- a/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js @@ -69,19 +69,23 @@ add_task(async function test_network_offline() { "view-syncedtabs:not([slot=syncedtabs])" ); await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); - await BrowserTestUtils.waitForMutationCondition( - syncedTabsComponent.shadowRoot.querySelector(".cards-container"), - { childList: true }, - () => syncedTabsComponent.shadowRoot.innerHTML.includes("network-offline") + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "Check your internet connection" + ), + "The expected network offline error message is displayed." ); - let emptyState = - syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); ok( - emptyState.getAttribute("headerlabel").includes("network-offline"), + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("network-offline"), "Network offline message is shown" ); - emptyState.querySelector("button[data-action='network-offline']").click(); + syncedTabsComponent.emptyState + .querySelector("button[data-action='network-offline']") + .click(); await BrowserTestUtils.waitForCondition( () => TabsSetupFlowManager.tryToClearError.calledOnce @@ -92,10 +96,10 @@ add_task(async function test_network_offline() { "TabsSetupFlowManager.tryToClearError() was called once" ); - emptyState = - syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); ok( - emptyState.getAttribute("headerlabel").includes("network-offline"), + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("network-offline"), "Network offline message is still shown" ); @@ -121,16 +125,18 @@ add_task(async function test_sync_error() { "view-syncedtabs:not([slot=syncedtabs])" ); await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); - await BrowserTestUtils.waitForMutationCondition( - syncedTabsComponent.shadowRoot.querySelector(".cards-container"), - { childList: true }, - () => syncedTabsComponent.shadowRoot.innerHTML.includes("sync-error") + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "having trouble syncing" + ), + "Sync error message is shown." ); - let emptyState = - syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); ok( - emptyState.getAttribute("headerlabel").includes("sync-error"), + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("sync-error"), "Correct message should show when there's a sync service error" ); @@ -139,3 +145,233 @@ add_task(async function test_sync_error() { }); await tearDown(sandbox); }); + +add_task(async function test_sync_disabled_by_policy() { + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + const recentBrowsingSyncedTabs = document.querySelector( + "view-syncedtabs[slot=syncedtabs]" + ); + const syncedtabsPageNavButton = document.querySelector( + "moz-page-nav-button[view='syncedtabs']" + ); + + ok( + BrowserTestUtils.isHidden(recentBrowsingSyncedTabs), + "Synced tabs should not be visible from recent browsing." + ); + ok( + BrowserTestUtils.isHidden(syncedtabsPageNavButton), + "Synced tabs nav button should not be visible." + ); + + document.location.assign(`${getFirefoxViewURL()}#syncedtabs`); + await TestUtils.waitForTick(); + is( + document.querySelector("moz-page-nav").currentView, + "recentbrowsing", + "Should not be able to navigate to synced tabs." + ); + }); + await tearDown(); +}); + +add_task(async function test_sync_error_signed_out() { + // sync error should not show if user is not signed in + let sandbox = await setupWithDesktopDevices(UIState.STATUS_NOT_CONFIGURED); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "The synced tabs component has finished updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "sign in to your account" + ), + "Sign in header is shown." + ); + + ok( + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("signin-header"), + "Sign in message is shown" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_disconnected_error() { + // it's possible for fxa to be enabled but sync not enabled. + const sandbox = setupSyncFxAMocks({ + state: UIState.STATUS_SIGNED_IN, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + + // triggered when user disconnects sync in about:preferences + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + info("Waiting for the synced tabs error step to be visible"); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "The synced tabs component has finished updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "allow syncing" + ), + "The expected synced tabs empty state header is shown." + ); + + info( + "Waiting for a mutation condition to ensure the right syncing error message" + ); + ok( + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("sync-disconnected-header"), + "Correct message should show when sync's been disconnected error" + ); + + let preferencesTabPromise = BrowserTestUtils.waitForNewTab( + browser.getTabBrowser(), + "about:preferences?action=choose-what-to-sync#sync", + true + ); + let emptyStateButton = syncedTabsComponent.emptyState.querySelector( + "button[data-action='sync-disconnected']" + ); + EventUtils.synthesizeMouseAtCenter(emptyStateButton, {}, content); + let preferencesTab = await preferencesTabPromise; + await BrowserTestUtils.removeTab(preferencesTab); + }); + await tearDown(sandbox); +}); + +add_task(async function test_password_change_disconnect_error() { + // When the user changes their password on another device, we get into a state + // where the user is signed out but sync is still enabled. + const sandbox = setupSyncFxAMocks({ + state: UIState.STATUS_LOGIN_FAILED, + syncEnabled: true, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + + // triggered by the user changing fxa password on another device + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "The synced tabs component has finished updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "sign in to your account" + ), + "The expected synced tabs empty state header is shown." + ); + + ok( + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("signin-header"), + "Sign in message is shown" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_multiple_errors() { + let sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + // Simulate conditions in which both the locked password and sync error + // messages could be shown + LoginTestUtils.primaryPassword.enable(); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "The synced tabs component has finished updating." + ); + info("Waiting for the primary password error message to be shown"); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "enter the Primary Password" + ), + "The expected synced tabs empty state header is shown." + ); + + ok( + syncedTabsComponent.emptyState + .getAttribute("headerlabel") + .includes("password-locked-header"), + "Password locked message is shown" + ); + + const errorLink = syncedTabsComponent.emptyState.shadowRoot.querySelector( + "a[data-l10n-name=syncedtab-password-locked-link]" + ); + ok( + errorLink && BrowserTestUtils.isVisible(errorLink), + "Error link is visible" + ); + + // Clear the primary password error message + LoginTestUtils.primaryPassword.disable(); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + info("Waiting for the sync error message to be shown"); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "The synced tabs component has finished updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.emptyState.shadowRoot.textContent.includes( + "having trouble syncing" + ), + "The expected synced tabs empty state header is shown." + ); + + ok( + errorLink && BrowserTestUtils.isHidden(errorLink), + "Error link is now hidden" + ); + + // Clear the sync error + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js index 11f135cd52..1bf387f578 100644 --- a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js @@ -276,9 +276,12 @@ add_task(async function test_tabs() { }); await withFirefoxView({ openNewWindow: true }, async browser => { + // Notify observers while in recent browsing. Once synced tabs is selected, + // it should have the updated data. + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + const { document } = browser.contentWindow; await navigateToViewAndWait(document, "syncedtabs"); - Services.obs.notifyObservers(null, UIState.ON_UPDATE); let syncedTabsComponent = document.querySelector( "view-syncedtabs:not([slot=syncedtabs])" @@ -309,7 +312,7 @@ add_task(async function test_tabs() { ); ok(tabRow1[1].shadowRoot.textContent.includes, "Sandboxes - Sinon.JS"); is(tabRow1.length, 2, "Correct number of rows are displayed."); - let tabRow2 = tabLists[1].shadowRoot.querySelectorAll("fxview-tab-row"); + let tabRow2 = tabLists[1].rowEls; is(tabRow2.length, 2, "Correct number of rows are dispayed."); ok(tabRow1[0].shadowRoot.textContent.includes, "The Guardian"); ok(tabRow1[1].shadowRoot.textContent.includes, "The Times"); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_list_keyboard_navigation.js b/browser/components/firefoxview/tests/browser/browser_tab_list_keyboard_navigation.js index d83c1056e0..270c3b6809 100644 --- a/browser/components/firefoxview/tests/browser/browser_tab_list_keyboard_navigation.js +++ b/browser/components/firefoxview/tests/browser/browser_tab_list_keyboard_navigation.js @@ -93,7 +93,7 @@ add_task(async function test_focus_moves_after_unmute() { ); // Unmute using keyboard - card.tabList.currentActiveElementId = mutedTab.focusMediaButton(); + mutedTab.focusMediaButton(); isActiveElement(mutedTab.mediaButtonEl); info("The media button has focus."); @@ -124,7 +124,7 @@ add_task(async function test_focus_moves_after_unmute() { ); mutedTab = card.tabList.rowEls[0]; - card.tabList.currentActiveElementId = mutedTab.focusLink(); + mutedTab.focusLink(); isActiveElement(mutedTab.mainEl); info("The 'main' element has focus."); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js index 9980980c29..a63a55163a 100644 --- a/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js +++ b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js @@ -31,7 +31,7 @@ add_task( info("Opening Firefox View tab..."); await openFirefoxViewTab(win); info("Trigger warnAboutClosingWindow()"); - win.BrowserTryToCloseWindow(); + win.BrowserCommands.tryToCloseWindow(); await BrowserTestUtils.closeWindow(win); ok(!dialogObserver.wasOpened, "Dialog was not opened"); dialogObserver.cleanup(); diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html index e48f776592..52ddc277c7 100644 --- a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html +++ b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html @@ -11,11 +11,6 @@ <script type="module" src="chrome://browser/content/firefoxview/fxview-tab-list.mjs"></script> </head> <body> - <style> - fxview-tab-list.history::part(secondary-button) { - background-image: url("chrome://global/skin/icons/more.svg"); - } - </style> <p id="display"></p> <div id="content" style="max-width: 750px"> <fxview-tab-list class="history" .dateTimeFormat="relative" .hasPopup="menu"> diff --git a/browser/components/ion/content/ion.js b/browser/components/ion/content/ion.js index 3c34328d58..ef3217d239 100644 --- a/browser/components/ion/content/ion.js +++ b/browser/components/ion/content/ion.js @@ -444,7 +444,7 @@ async function setup(cachedAddons) { document .getElementById("join-ion-accept-dialog-button") - .addEventListener("click", async event => { + .addEventListener("click", async () => { const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); if (!ionId) { @@ -501,7 +501,7 @@ async function setup(cachedAddons) { document .getElementById("leave-ion-accept-dialog-button") - .addEventListener("click", async event => { + .addEventListener("click", async () => { const completedStudies = Services.prefs.getStringPref( PREF_ION_COMPLETED_STUDIES, "{}" @@ -567,7 +567,7 @@ async function setup(cachedAddons) { document .getElementById("join-study-accept-dialog-button") - .addEventListener("click", async event => { + .addEventListener("click", async () => { const dialog = document.getElementById("join-study-consent-dialog"); const studyAddonId = dialog.getAttribute("addon-id"); toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); @@ -575,7 +575,7 @@ async function setup(cachedAddons) { document .getElementById("leave-study-accept-dialog-button") - .addEventListener("click", async event => { + .addEventListener("click", async () => { const dialog = document.getElementById("leave-study-consent-dialog"); const studyAddonId = dialog.getAttribute("addon-id"); await toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); @@ -597,7 +597,7 @@ async function setup(cachedAddons) { }; AddonManager.addAddonListener(addonsListener); - window.addEventListener("unload", event => { + window.addEventListener("unload", () => { AddonManager.removeAddonListener(addonsListener); }); } @@ -639,7 +639,7 @@ function updateContents(contents) { } } -document.addEventListener("DOMContentLoaded", async domEvent => { +document.addEventListener("DOMContentLoaded", async () => { toggleContentBasedOnLocale(); showEnrollmentStatus(); diff --git a/browser/components/ion/test/browser/browser_ion_ui.js b/browser/components/ion/test/browser/browser_ion_ui.js index e956cefa25..3cf3c47f96 100644 --- a/browser/components/ion/test/browser/browser_ion_ui.js +++ b/browser/components/ion/test/browser/browser_ion_ui.js @@ -321,7 +321,7 @@ add_task(async function testBadDefaultAddon() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); Assert.strictEqual( beforePref, @@ -402,7 +402,7 @@ add_task(async function testAboutPage() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); Assert.strictEqual( beforePref, @@ -694,19 +694,16 @@ add_task(async function testAboutPage() { // 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); - } + Services.prefs.addObserver(PREF_ION_ID, function observer() { + 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."); @@ -795,7 +792,7 @@ add_task(async function testEnrollmentPings() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); Assert.strictEqual( beforePref, @@ -984,7 +981,7 @@ add_task(async function testContentReplacement() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { // Check that text was updated from Remote Settings. console.log("debug:", content.document.getElementById("title").innerHTML); Assert.equal( @@ -1042,7 +1039,7 @@ add_task(async function testBadContentReplacement() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { // Check that text was updated from Remote Settings. Assert.equal( content.document.getElementById("join-ion-consent").innerHTML, @@ -1081,7 +1078,7 @@ add_task(async function testLocaleGating() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { const localeNotificationBar = content.document.getElementById( "locale-notification" ); @@ -1107,7 +1104,7 @@ add_task(async function testLocaleGating() { url: "about:ion", gBrowser, }, - async function taskFn(browser) { + async function taskFn() { const localeNotificationBar = content.document.getElementById( "locale-notification" ); diff --git a/browser/components/messagepreview/messagepreview.js b/browser/components/messagepreview/messagepreview.js index 48e5fb1ff5..bec0a2d8eb 100644 --- a/browser/components/messagepreview/messagepreview.js +++ b/browser/components/messagepreview/messagepreview.js @@ -6,13 +6,25 @@ "use strict"; +// decode a 16-bit string in which only one byte of each +// 16-bit unit is occupied, to UTF-8. This is necessary to +// comply with `btoa` API constraints. +function fromBinary(encoded) { + const binary = atob(decodeURIComponent(encoded)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return String.fromCharCode(...new Uint16Array(bytes.buffer)); +} + function decodeMessageFromUrl() { const url = new URL(document.location.href); if (url.searchParams.has("json")) { const encodedMessage = url.searchParams.get("json"); - return atob(encodedMessage); + return fromBinary(encodedMessage); } return null; } diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js index 34d8ceec2d..41a71782f1 100644 --- a/browser/components/migration/.eslintrc.js +++ b/browser/components/migration/.eslintrc.js @@ -5,7 +5,6 @@ "use strict"; module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], rules: { "block-scoped-var": "error", complexity: ["error", { max: 22 }], @@ -14,7 +13,7 @@ module.exports = { "no-multi-str": "error", "no-return-assign": "error", "no-shadow": "error", - "no-unused-vars": ["error", { args: "after-used", vars: "all" }], + "no-unused-vars": ["error", { argsIgnorePattern: "^_", vars: "all" }], strict: ["error", "global"], yoda: "error", }, @@ -26,7 +25,7 @@ module.exports = { "no-unused-vars": [ "error", { - args: "none", + argsIgnorePattern: "^_", vars: "local", }, ], diff --git a/browser/components/migration/FileMigrators.sys.mjs b/browser/components/migration/FileMigrators.sys.mjs index 3384011c13..487d77aa6c 100644 --- a/browser/components/migration/FileMigrators.sys.mjs +++ b/browser/components/migration/FileMigrators.sys.mjs @@ -138,11 +138,10 @@ export class FileMigratorBase { * from the native file picker. This will not be called if the user * chooses to cancel the native file picker. * - * @param {string} filePath + * @param {string} _filePath * The path that the user selected from the native file picker. */ - // eslint-disable-next-line no-unused-vars - async migrate(filePath) { + async migrate(_filePath) { throw new Error("FileMigrator.migrate must be overridden."); } } diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs index 52bfc87b3e..32bed4e6ec 100644 --- a/browser/components/migration/MigratorBase.sys.mjs +++ b/browser/components/migration/MigratorBase.sys.mjs @@ -141,7 +141,7 @@ export class MigratorBase { * bookmarks file exists. * * @abstract - * @param {object|string} aProfile + * @param {object|string} _aProfile * The profile from which data may be imported, or an empty string * in the case of a single-profile migrator. * In the case of multiple-profiles migrator, it is guaranteed that @@ -149,8 +149,7 @@ export class MigratorBase { * above). * @returns {Promise<MigratorResource[]>|MigratorResource[]} */ - // eslint-disable-next-line no-unused-vars - getResources(aProfile) { + getResources(_aProfile) { throw new Error("getResources must be overridden"); } @@ -223,14 +222,13 @@ export class MigratorBase { * to getPermissions resolves to true, that the MigratorBase will be able to * get read access to all of the resources it needs to do a migration. * - * @param {DOMWindow} win + * @param {DOMWindow} _win * The top-level DOM window hosting the UI that is requesting the permission. * This can be used to, for example, anchor a file picker window to the * same window that is hosting the migration UI. * @returns {Promise<boolean>} */ - // eslint-disable-next-line no-unused-vars - async getPermissions(win) { + async getPermissions(_win) { return Promise.resolve(true); } diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs index 6fc7a715d7..89872a1558 100644 --- a/browser/components/migration/content/migration-wizard.mjs +++ b/browser/components/migration/content/migration-wizard.mjs @@ -583,9 +583,14 @@ export class MigrationWizard extends HTMLElement { "div[name='page-selection']" ); + let header = selectionPage.querySelector(".migration-wizard-header"); + let selectionHeaderString = this.getAttribute("selection-header-string"); + if (this.hasAttribute("selection-header-string")) { - selectionPage.querySelector(".migration-wizard-header").textContent = - this.getAttribute("selection-header-string"); + header.textContent = selectionHeaderString; + header.toggleAttribute("hidden", !selectionHeaderString); + } else { + header.removeAttribute("hidden"); } let selectionSubheaderString = this.getAttribute( diff --git a/browser/components/newtab/.eslintrc.js b/browser/components/newtab/.eslintrc.js index f541cdd988..29114a055a 100644 --- a/browser/components/newtab/.eslintrc.js +++ b/browser/components/newtab/.eslintrc.js @@ -15,11 +15,7 @@ module.exports = { { // TODO: Bug 1773467 - Move these to .mjs or figure out a generic way // to identify these as modules. - files: [ - "content-src/**/*.js", - "test/schemas/**/*.js", - "test/unit/**/*.js", - ], + files: ["test/schemas/**/*.js", "test/unit/**/*.js"], parserOptions: { sourceType: "module", }, @@ -92,8 +88,6 @@ module.exports = { }, ], rules: { - "fetch-options/no-fetch-credentials": "error", - "react/jsx-boolean-value": ["error", "always"], "react/jsx-key": "error", "react/jsx-no-bind": [ diff --git a/browser/components/newtab/common/Actions.sys.mjs b/browser/components/newtab/common/Actions.mjs index df5c9f0c91..7273d80220 100644 --- a/browser/components/newtab/common/Actions.sys.mjs +++ b/browser/components/newtab/common/Actions.mjs @@ -2,6 +2,8 @@ * 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 accessed from both content and system scopes. + export const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; export const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; export const PRELOAD_MESSAGE_TYPE = "ActivityStream:PreloadedBrowser"; @@ -158,6 +160,7 @@ for (const type of [ "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", + "WALLPAPERS_SET", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { @@ -371,8 +374,11 @@ function DiscoveryStreamLoadedContent( return importContext === UI_CODE ? AlsoToMain(action) : action; } -function SetPref(name, value, importContext = globalImportContext) { - const action = { type: actionTypes.SET_PREF, data: { name, value } }; +function SetPref(prefName, value, importContext = globalImportContext) { + const action = { + type: actionTypes.SET_PREF, + data: { name: prefName, value }, + }; return importContext === UI_CODE ? AlsoToMain(action) : action; } diff --git a/browser/components/newtab/common/Reducers.sys.mjs b/browser/components/newtab/common/Reducers.sys.mjs index d4f879b834..326217538d 100644 --- a/browser/components/newtab/common/Reducers.sys.mjs +++ b/browser/components/newtab/common/Reducers.sys.mjs @@ -2,7 +2,7 @@ * 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/. */ -import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs"; import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs"; export const TOP_SITES_DEFAULT_ROWS = 1; @@ -101,6 +101,9 @@ export const INITIAL_STATE = { // Hide the search box after handing off to AwesomeBar and user starts typing. hide: false, }, + Wallpapers: { + wallpaperList: [], + }, }; function App(prevState = INITIAL_STATE.App, action) { @@ -841,6 +844,15 @@ function Search(prevState = INITIAL_STATE.Search, action) { } } +function Wallpapers(prevState = INITIAL_STATE.Wallpapers, action) { + switch (action.type) { + case at.WALLPAPERS_SET: + return { wallpaperList: action.data }; + default: + return prevState; + } +} + export const reducers = { TopSites, App, @@ -852,4 +864,5 @@ export const reducers = { Personalization, DiscoveryStream, Search, + Wallpapers, }; diff --git a/browser/components/newtab/content-src/activity-stream.jsx b/browser/components/newtab/content-src/activity-stream.jsx index c588e8e850..57ba9f9c92 100644 --- a/browser/components/newtab/content-src/activity-stream.jsx +++ b/browser/components/newtab/content-src/activity-stream.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { Base } from "content-src/components/Base/Base"; import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; import { initStore } from "content-src/lib/init-store"; diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx index 20402b09f5..1738f8f51a 100644 --- a/browser/components/newtab/content-src/components/Base/Base.jsx +++ b/browser/components/newtab/content-src/components/Base/Base.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { DiscoveryStreamAdmin } from "content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin"; import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; import { connect } from "react-redux"; @@ -16,6 +13,9 @@ import React from "react"; import { Search } from "content-src/components/Search/Search"; import { Sections } from "content-src/components/Sections/Sections"; +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + export const PrefsButton = ({ onClick, icon }) => ( <div className="prefs-button"> <button @@ -76,7 +76,7 @@ export class _Base extends React.PureComponent { ] .filter(v => v) .join(" "); - global.document.body.className = bodyClassName; + globalThis.document.body.className = bodyClassName; } render() { @@ -110,17 +110,75 @@ export class BaseContent extends React.PureComponent { this.handleOnKeyDown = this.handleOnKeyDown.bind(this); this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5); this.setPref = this.setPref.bind(this); - this.state = { fixedSearch: false }; + this.updateWallpaper = this.updateWallpaper.bind(this); + this.prefersDarkQuery = null; + this.handleColorModeChange = this.handleColorModeChange.bind(this); + this.state = { + fixedSearch: false, + firstVisibleTimestamp: null, + colorMode: "", + }; + } + + setFirstVisibleTimestamp() { + if (!this.state.firstVisibleTimestamp) { + this.setState({ + firstVisibleTimestamp: Date.now(), + }); + } } componentDidMount() { global.addEventListener("scroll", this.onWindowScroll); global.addEventListener("keydown", this.handleOnKeyDown); + if (this.props.document.visibilityState === VISIBLE) { + this.setFirstVisibleTimestamp(); + } else { + this._onVisibilityChange = () => { + if (this.props.document.visibilityState === VISIBLE) { + this.setFirstVisibleTimestamp(); + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + this._onVisibilityChange = null; + } + }; + this.props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + // track change event to dark/light mode + this.prefersDarkQuery = globalThis.matchMedia( + "(prefers-color-scheme: dark)" + ); + + this.prefersDarkQuery.addEventListener( + "change", + this.handleColorModeChange + ); + this.handleColorModeChange(); + } + + handleColorModeChange() { + const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; + this.setState({ colorMode }); } componentWillUnmount() { + this.prefersDarkQuery?.removeEventListener( + "change", + this.handleColorModeChange + ); global.removeEventListener("scroll", this.onWindowScroll); global.removeEventListener("keydown", this.handleOnKeyDown); + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } } onWindowScroll() { @@ -160,11 +218,79 @@ export class BaseContent extends React.PureComponent { this.props.dispatch(ac.SetPref(pref, value)); } + renderWallpaperAttribution() { + const { wallpaperList } = this.props.Wallpapers; + const activeWallpaper = + this.props.Prefs.values[ + `newtabWallpapers.wallpaper-${this.state.colorMode}` + ]; + const selected = wallpaperList.find(wp => wp.title === activeWallpaper); + // make sure a wallpaper is selected and that the attribution also exists + if (!selected?.attribution) { + return null; + } + + const { name, webpage } = selected.attribution; + if (activeWallpaper && wallpaperList && name.url) { + return ( + <p + className={`wallpaper-attribution`} + key={name} + data-l10n-id="newtab-wallpaper-attribution" + data-l10n-args={JSON.stringify({ + author_string: name.string, + author_url: name.url, + webpage_string: webpage.string, + webpage_url: webpage.url, + })} + > + <a data-l10n-name="name-link" href={name.url}> + {name.string} + </a> + <a data-l10n-name="webpage-link" href={webpage.url}> + {webpage.string} + </a> + </p> + ); + } + return null; + } + + async updateWallpaper() { + const prefs = this.props.Prefs.values; + const { wallpaperList } = this.props.Wallpapers; + + if (wallpaperList) { + const lightWallpaper = + wallpaperList.find( + wp => wp.title === prefs["newtabWallpapers.wallpaper-light"] + ) || ""; + const darkWallpaper = + wallpaperList.find( + wp => wp.title === prefs["newtabWallpapers.wallpaper-dark"] + ) || ""; + global.document?.body.style.setProperty( + `--newtab-wallpaper-light`, + `url(${lightWallpaper?.wallpaperUrl || ""})` + ); + + global.document?.body.style.setProperty( + `--newtab-wallpaper-dark`, + `url(${darkWallpaper?.wallpaperUrl || ""})` + ); + } + } + render() { const { props } = this; const { App } = props; const { initialized, customizeMenuVisible } = App; const prefs = props.Prefs.values; + + const activeWallpaper = + prefs[`newtabWallpapers.wallpaper-${this.state.colorMode}`]; + const wallpapersEnabled = prefs["newtabWallpapers.enabled"]; + const { pocketConfig } = prefs; const isDiscoveryStream = @@ -215,6 +341,9 @@ export class BaseContent extends React.PureComponent { ] .filter(v => v) .join(" "); + if (wallpapersEnabled) { + this.updateWallpaper(); + } return ( <div> @@ -224,6 +353,8 @@ export class BaseContent extends React.PureComponent { openPreferences={this.openPreferences} setPref={this.setPref} enabledSections={enabledSections} + wallpapersEnabled={wallpapersEnabled} + activeWallpaper={activeWallpaper} pocketRegion={pocketRegion} mayHaveSponsoredTopSites={mayHaveSponsoredTopSites} mayHaveSponsoredStories={mayHaveSponsoredStories} @@ -252,6 +383,7 @@ export class BaseContent extends React.PureComponent { <DiscoveryStreamBase locale={props.App.locale} mayHaveSponsoredStories={mayHaveSponsoredStories} + firstVisibleTimestamp={this.state.firstVisibleTimestamp} /> </ErrorBoundary> ) : ( @@ -259,6 +391,7 @@ export class BaseContent extends React.PureComponent { )} </div> <ConfirmDialog /> + {wallpapersEnabled && this.renderWallpaperAttribution()} </main> </div> </div> @@ -266,10 +399,15 @@ export class BaseContent extends React.PureComponent { } } +BaseContent.defaultProps = { + document: global.document, +}; + export const Base = connect(state => ({ App: state.App, Prefs: state.Prefs, Sections: state.Sections, DiscoveryStream: state.DiscoveryStream, Search: state.Search, + Wallpapers: state.Wallpapers, }))(_Base); diff --git a/browser/components/newtab/content-src/components/Base/_Base.scss b/browser/components/newtab/content-src/components/Base/_Base.scss index 1282173df5..a9141e0923 100644 --- a/browser/components/newtab/content-src/components/Base/_Base.scss +++ b/browser/components/newtab/content-src/components/Base/_Base.scss @@ -24,10 +24,17 @@ } main { - margin: auto; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; width: $wrapper-default-width; padding: 0; + .vertical-center-wrapper { + margin: auto 0; + } + section { margin-bottom: $section-spacing; position: relative; @@ -124,3 +131,32 @@ main { } } } + +.wallpaper-attribution { + padding: 0 $section-horizontal-padding; + font-size: 14px; + + &.theme-light { + display: inline-block; + + @include dark-theme-only { + display: none; + } + } + + &.theme-dark { + display: none; + + @include dark-theme-only { + display: inline-block; + } + } + + a { + color: var(--newtab-element-color); + + &:hover { + text-decoration: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/Card.jsx b/browser/components/newtab/content-src/components/Card/Card.jsx index 9d03377f1b..da5e0346d7 100644 --- a/browser/components/newtab/content-src/components/Card/Card.jsx +++ b/browser/components/newtab/content-src/components/Card/Card.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { cardContextTypes } from "./types"; import { connect } from "react-redux"; import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; diff --git a/browser/components/newtab/content-src/components/Card/types.js b/browser/components/newtab/content-src/components/Card/types.mjs index 0b17eea408..0b17eea408 100644 --- a/browser/components/newtab/content-src/components/Card/types.js +++ b/browser/components/newtab/content-src/components/Card/types.mjs diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx index 98bf88fbea..2046617ad6 100644 --- a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -119,7 +119,7 @@ export class _CollapsibleSection extends React.PureComponent { } _CollapsibleSection.defaultProps = { - document: global.document || { + document: globalThis.document || { addEventListener: () => {}, removeEventListener: () => {}, visibilityState: "hidden", diff --git a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx index 4efd8c712e..ffcc6b62f4 100644 --- a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx +++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { perfService as perfSvc } from "content-src/lib/perf-service"; import React from "react"; diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx index f69e540079..734f261b27 100644 --- a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx +++ b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac, actionTypes } from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes } from "common/Actions.mjs"; import { connect } from "react-redux"; import React from "react"; diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx index 5ea6a57f71..458f65e644 100644 --- a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx @@ -26,12 +26,12 @@ export class ContextMenu extends React.PureComponent { componentDidMount() { this.onShow(); setTimeout(() => { - global.addEventListener("click", this.hideContext); + globalThis.addEventListener("click", this.hideContext); }, 0); } componentWillUnmount() { - global.removeEventListener("click", this.hideContext); + globalThis.removeEventListener("click", this.hideContext); } onClick(event) { diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx index 298dedcee5..1dd13fc965 100644 --- a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -3,8 +3,9 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from "react"; -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { SafeAnchor } from "../../DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; +import { WallpapersSection } from "../../WallpapersSection/WallpapersSection"; export class ContentSection extends React.PureComponent { constructor(props) { @@ -98,6 +99,9 @@ export class ContentSection extends React.PureComponent { mayHaveRecentSaves, openPreferences, spocMessageVariant, + wallpapersEnabled, + activeWallpaper, + setPref, } = this.props; const { topSitesEnabled, @@ -111,6 +115,15 @@ export class ContentSection extends React.PureComponent { return ( <div className="home-section"> + {wallpapersEnabled && ( + <div className="wallpapers-section"> + <h2 data-l10n-id="newtab-wallpaper-title"></h2> + <WallpapersSection + setPref={setPref} + activeWallpaper={activeWallpaper} + /> + </div> + )} <div id="shortcuts-section" className="section"> <moz-toggle id="shortcuts-toggle" diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx index 54dcd550c4..f1c723fed2 100644 --- a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx @@ -2,7 +2,6 @@ * 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/. */ -import { BackgroundsSection } from "content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection"; import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; import { connect } from "react-redux"; import React from "react"; @@ -62,11 +61,12 @@ export class _CustomizeMenu extends React.PureComponent { data-l10n-id="newtab-custom-close-button" ref={c => (this.closeButton = c)} /> - <BackgroundsSection /> <ContentSection openPreferences={this.props.openPreferences} setPref={this.props.setPref} enabledSections={this.props.enabledSections} + wallpapersEnabled={this.props.wallpapersEnabled} + activeWallpaper={this.props.activeWallpaper} pocketRegion={this.props.pocketRegion} mayHaveSponsoredTopSites={this.props.mayHaveSponsoredTopSites} mayHaveSponsoredStories={this.props.mayHaveSponsoredStories} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss index 579e455a3f..c20da5ce50 100644 --- a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss +++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss @@ -119,6 +119,10 @@ grid-row-gap: 32px; padding: 0 16px; + .wallpapers-section h2 { + font-size: inherit; + } + .section { moz-toggle { margin-bottom: 10px; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx index 3c31a5a29f..8b9d64dfc1 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { connect } from "react-redux"; import React from "react"; import { SimpleHashRouter } from "./SimpleHashRouter"; @@ -445,9 +442,9 @@ export class CollapseToggle extends React.PureComponent { setBodyClass() { if (this.renderAdmin && !this.state.collapsed) { - global.document.body.classList.add("no-scroll"); + globalThis.document.body.classList.add("no-scroll"); } else { - global.document.body.classList.remove("no-scroll"); + globalThis.document.body.classList.remove("no-scroll"); } } @@ -460,7 +457,7 @@ export class CollapseToggle extends React.PureComponent { } componentWillUnmount() { - global.document.body.classList.remove("no-scroll"); + globalThis.document.body.classList.remove("no-scroll"); } render() { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx index 9c3fd8579c..bc7b0c42c5 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx @@ -8,19 +8,19 @@ export class SimpleHashRouter extends React.PureComponent { constructor(props) { super(props); this.onHashChange = this.onHashChange.bind(this); - this.state = { hash: global.location.hash }; + this.state = { hash: globalThis.location.hash }; } onHashChange() { - this.setState({ hash: global.location.hash }); + this.setState({ hash: globalThis.location.hash }); } componentWillMount() { - global.addEventListener("hashchange", this.onHashChange); + globalThis.addEventListener("hashchange", this.onHashChange); } componentWillUnmount() { - global.removeEventListener("hashchange", this.onHashChange); + globalThis.removeEventListener("hashchange", this.onHashChange); } render() { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx index 0f0ee51ab9..8b5826dd82 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -164,7 +164,7 @@ export class _DiscoveryStreamBase extends React.PureComponent { privacyNoticeURL={component.properties.privacyNoticeURL} /> ); - case "CollectionCardGrid": + case "CollectionCardGrid": { const { DiscoveryStream } = this.props; return ( <CollectionCardGrid @@ -178,6 +178,7 @@ export class _DiscoveryStreamBase extends React.PureComponent { dispatch={this.props.dispatch} /> ); + } case "CardGrid": return ( <CardGrid @@ -200,6 +201,7 @@ export class _DiscoveryStreamBase extends React.PureComponent { editorsPicksHeader={component.properties.editorsPicksHeader} recentSavesEnabled={this.props.DiscoveryStream.recentSavesEnabled} hideDescriptions={this.props.DiscoveryStream.hideDescriptions} + firstVisibleTimestamp={this.props.firstVisibleTimestamp} /> ); case "HorizontalRule": @@ -384,6 +386,6 @@ export const DiscoveryStreamBase = connect(state => ({ DiscoveryStream: state.DiscoveryStream, Prefs: state.Prefs, Sections: state.Sections, - document: global.document, + document: globalThis.document, App: state.App, }))(_DiscoveryStreamBase); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx index cf00361df2..2a9497d1b4 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -8,10 +8,7 @@ import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDi import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import React, { useEffect, useState, useRef, useCallback } from "react"; import { connect, useSelector } from "react-redux"; const PREF_ONBOARDING_EXPERIENCE_DISMISSED = @@ -31,7 +28,7 @@ export function DSSubHeader({ children }) { ); } -export function OnboardingExperience({ dispatch, windowObj = global }) { +export function OnboardingExperience({ dispatch, windowObj = globalThis }) { const [dismissed, setDismissed] = useState(false); const [maxHeight, setMaxHeight] = useState(null); const heightElement = useRef(null); @@ -361,6 +358,7 @@ export class _CardGrid extends React.PureComponent { url={rec.url} id={rec.id} shim={rec.shim} + fetchTimestamp={rec.fetchTimestamp} type={this.props.type} context={rec.context} sponsor={rec.sponsor} @@ -377,6 +375,7 @@ export class _CardGrid extends React.PureComponent { ctaButtonVariant={ctaButtonVariant} spocMessageVariant={spocMessageVariant} recommendation_id={rec.recommendation_id} + firstVisibleTimestamp={this.props.firstVisibleTimestamp} /> ) ); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx index d089a5c8ab..4f3f150a9b 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; import { LinkMenuOptions } from "content-src/lib/link-menu-options"; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx index f3e1eab503..b3d965530d 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { DSImage } from "../DSImage/DSImage.jsx"; import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; @@ -198,6 +195,8 @@ export class _DSCard extends React.PureComponent { ...(this.props.shim && this.props.shim.click ? { shim: this.props.shim.click } : {}), + fetchTimestamp: this.props.fetchTimestamp, + firstVisibleTimestamp: this.props.firstVisibleTimestamp, }, }) ); @@ -245,6 +244,8 @@ export class _DSCard extends React.PureComponent { ...(this.props.shim && this.props.shim.save ? { shim: this.props.shim.save } : {}), + fetchTimestamp: this.props.fetchTimestamp, + firstVisibleTimestamp: this.props.firstVisibleTimestamp, }, }) ); @@ -441,10 +442,12 @@ export class _DSCard extends React.PureComponent { ? { shim: this.props.shim.impression } : {}), recommendation_id: this.props.recommendation_id, + fetchTimestamp: this.props.fetchTimestamp, }, ]} dispatch={this.props.dispatch} source={this.props.type} + firstVisibleTimestamp={this.props.firstVisibleTimestamp} /> </SafeAnchor> {ctaButtonVariant === "variant-b" && ( diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx index 6c0641cfc1..80af05c585 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { cardContextTypes } from "../../Card/types.js"; +import { cardContextTypes } from "../../Card/types.mjs"; import { SponsoredContentHighlight } from "../FeatureHighlight/SponsoredContentHighlight"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx index ff3886b407..ed90f68606 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import React from "react"; export class DSEmptyState extends React.PureComponent { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx index b75063940c..107adca4da 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -4,7 +4,7 @@ import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import React from "react"; export class DSLinkMenu extends React.PureComponent { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx index b251fb0401..2275f8b22b 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx @@ -3,10 +3,7 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from "react"; -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; export class DSPrivacyModal extends React.PureComponent { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx index b7e3205646..0a4d687c65 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx index 02a3326eb7..fc52decdf8 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; import { DSImage } from "../DSImage/DSImage.jsx"; import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/FeatureHighlight/FeatureHighlight.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/FeatureHighlight/FeatureHighlight.jsx index 792be40ba3..c650453393 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/FeatureHighlight/FeatureHighlight.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/FeatureHighlight/FeatureHighlight.jsx @@ -3,7 +3,7 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ import React, { useState, useCallback, useRef, useEffect } from "react"; -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; export function FeatureHighlight({ message, diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx index 1062c3cade..43865c177c 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import React from "react"; import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx index 72ec94e1fe..b586730713 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import React from "react"; export class SafeAnchor extends React.PureComponent { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx index 1fe2343b94..59b44198a2 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx @@ -3,7 +3,7 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from "react"; -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; import { connect } from "react-redux"; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx index 1eb4863271..9342fcd27a 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants"; import React from "react"; @@ -100,7 +97,9 @@ export class ImpressionStats extends React.PureComponent { type: this.props.flightId ? "spoc" : "organic", ...(link.shim ? { shim: link.shim } : {}), recommendation_id: link.recommendation_id, + fetchTimestamp: link.fetchTimestamp, })), + firstVisibleTimestamp: this.props.firstVisibleTimestamp, }) ); this.impressionCardGuids = cards.map(link => link.id); @@ -244,8 +243,8 @@ export class ImpressionStats extends React.PureComponent { } ImpressionStats.defaultProps = { - IntersectionObserver: global.IntersectionObserver, - document: global.document, + IntersectionObserver: globalThis.IntersectionObserver, + document: globalThis.document, rows: [], source: "", }; diff --git a/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx index 650a03eb95..65b1f38623 100644 --- a/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx +++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { connect } from "react-redux"; import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu"; import { LinkMenuOptions } from "content-src/lib/link-menu-options"; diff --git a/browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx index fdfdf22db2..5d902b43ba 100644 --- a/browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx +++ b/browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx @@ -53,4 +53,4 @@ export class ModalOverlayWrapper extends React.PureComponent { } } -ModalOverlayWrapper.defaultProps = { document: global.document }; +ModalOverlayWrapper.defaultProps = { document: globalThis.document }; diff --git a/browser/components/newtab/content-src/components/Search/Search.jsx b/browser/components/newtab/content-src/components/Search/Search.jsx index 64308963c9..ef7a3757d3 100644 --- a/browser/components/newtab/content-src/components/Search/Search.jsx +++ b/browser/components/newtab/content-src/components/Search/Search.jsx @@ -4,10 +4,7 @@ /* globals ContentSearchUIController, ContentSearchHandoffUIController */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { connect } from "react-redux"; import { IS_NEWTAB } from "content-src/lib/constants"; import React from "react"; diff --git a/browser/components/newtab/content-src/components/Sections/Sections.jsx b/browser/components/newtab/content-src/components/Sections/Sections.jsx index e72e9145ad..01b50f6918 100644 --- a/browser/components/newtab/content-src/components/Sections/Sections.jsx +++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { Card, PlaceholderCard } from "content-src/components/Card/Card"; import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; @@ -33,7 +30,7 @@ export class Section extends React.PureComponent { let cardsPerRow = CARDS_PER_ROW_DEFAULT; if ( props.compactCards && - global.matchMedia(`(min-width: 1072px)`).matches + globalThis.matchMedia(`(min-width: 1072px)`).matches ) { // If the section has compact cards and the viewport is wide enough, we show // 4 columns instead of 3. @@ -326,7 +323,7 @@ export class Section extends React.PureComponent { } Section.defaultProps = { - document: global.document, + document: globalThis.document, rows: [], emptyState: {}, pref: {}, diff --git a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx index 4324c019f6..2d504c52ab 100644 --- a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx +++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import React from "react"; import { TOP_SITES_SOURCE } from "./TopSitesConstants"; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx index c0932104af..3d63398e0e 100644 --- a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { MIN_RICH_FAVICON_SIZE, MIN_SMALL_FAVICON_SIZE, diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx index 7dd61bdc93..9ca8991735 100644 --- a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; import React from "react"; import { TOP_SITES_SOURCE } from "./TopSitesConstants"; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx index 580809dd57..b654a803c7 100644 --- a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx @@ -2,7 +2,7 @@ * 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/. */ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import React from "react"; const VISIBLE = "visible"; @@ -142,8 +142,8 @@ export class TopSiteImpressionWrapper extends React.PureComponent { } TopSiteImpressionWrapper.defaultProps = { - IntersectionObserver: global.IntersectionObserver, - document: global.document, + IntersectionObserver: globalThis.IntersectionObserver, + document: globalThis.document, actionType: null, tile: null, }; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx index ba7676fd10..d9a12aa97d 100644 --- a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx +++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx @@ -2,10 +2,7 @@ * 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/. */ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE } from "./TopSitesConstants"; import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; @@ -93,7 +90,7 @@ export class _TopSites extends React.PureComponent { // We hide 2 sites per row when not in the wide layout. let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW; // $break-point-widest = 1072px (from _variables.scss) - if (!global.matchMedia(`(min-width: 1072px)`).matches) { + if (!globalThis.matchMedia(`(min-width: 1072px)`).matches) { sitesPerRow -= 2; } return this.props.TopSites.rows.slice( diff --git a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.mjs index f488896238..f488896238 100644 --- a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js +++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.mjs diff --git a/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx b/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx new file mode 100644 index 0000000000..0b51a146f5 --- /dev/null +++ b/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx @@ -0,0 +1,100 @@ +/* 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/. */ + +import React from "react"; +import { connect } from "react-redux"; + +export class _WallpapersSection extends React.PureComponent { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleReset = this.handleReset.bind(this); + this.prefersHighContrastQuery = null; + this.prefersDarkQuery = null; + } + + componentDidMount() { + this.prefersDarkQuery = globalThis.matchMedia( + "(prefers-color-scheme: dark)" + ); + } + + handleChange(event) { + const { id } = event.target; + const prefs = this.props.Prefs.values; + const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; + this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, id); + // bug 1892095 + if ( + prefs["newtabWallpapers.wallpaper-dark"] === "" && + colorMode === "light" + ) { + this.props.setPref( + "newtabWallpapers.wallpaper-dark", + id.replace("light", "dark") + ); + } + + if ( + prefs["newtabWallpapers.wallpaper-light"] === "" && + colorMode === "dark" + ) { + this.props.setPref( + `newtabWallpapers.wallpaper-light`, + id.replace("dark", "light") + ); + } + } + + handleReset() { + const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; + this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, ""); + } + + render() { + const { wallpaperList } = this.props.Wallpapers; + const { activeWallpaper } = this.props; + return ( + <div> + <fieldset className="wallpaper-list"> + {wallpaperList.map(({ title, theme, fluent_id }) => { + return ( + <> + <input + onChange={this.handleChange} + type="radio" + name={`wallpaper-${title}`} + id={title} + value={title} + checked={title === activeWallpaper} + aria-checked={title === activeWallpaper} + className={`wallpaper-input theme-${theme} ${title}`} + /> + <label + htmlFor={title} + className="sr-only" + data-l10n-id={fluent_id} + > + {fluent_id} + </label> + </> + ); + })} + </fieldset> + <button + className="wallpapers-reset" + onClick={this.handleReset} + data-l10n-id="newtab-wallpaper-reset" + /> + </div> + ); + } +} + +export const WallpapersSection = connect(state => { + return { + Wallpapers: state.Wallpapers, + Prefs: state.Prefs, + }; +})(_WallpapersSection); diff --git a/browser/components/newtab/content-src/components/WallpapersSection/_WallpapersSection.scss b/browser/components/newtab/content-src/components/WallpapersSection/_WallpapersSection.scss new file mode 100644 index 0000000000..689661750b --- /dev/null +++ b/browser/components/newtab/content-src/components/WallpapersSection/_WallpapersSection.scss @@ -0,0 +1,87 @@ +.wallpaper-list { + display: grid; + gap: 16px; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: 86px; + margin: 16px 0; + padding: 0; + border: none; + + .wallpaper-input, + .sr-only { + &.theme-light { + display: inline-block; + + @include dark-theme-only { + display: none; + } + } + + &.theme-dark { + display: none; + + @include dark-theme-only { + display: inline-block; + } + } + } + + .wallpaper-input { + appearance: none; + margin: 0; + padding: 0; + height: 86px; + width: 100%; + box-shadow: $shadow-secondary; + border-radius: 8px; + background-clip: content-box; + background-repeat: no-repeat; + background-size: cover; + cursor: pointer; + outline: 2px solid transparent; + + $wallpapers: dark-landscape, dark-color, dark-mountain, dark-panda, dark-sky, dark-beach, light-beach, light-color, light-landscape, light-mountain, light-panda, light-sky; + + @each $wallpaper in $wallpapers { + &.#{$wallpaper} { + background-image: url('chrome://activity-stream/content/data/content/assets/wallpapers/#{$wallpaper}.avif') + } + } + + &:checked { + outline-color: var(--color-accent-primary-active); + } + + &:focus-visible { + outline-color: var(--newtab-primary-action-background); + } + + &:hover { + filter: brightness(55%); + outline-color: transparent; + } + } + + // visually hide label, but still read by screen readers + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + pointer-events: none; + } +} + +.wallpapers-reset { + background: none; + border: none; + text-decoration: underline; + margin-inline: auto; + display: block; + font-size: var(--font-size-small); + color: var(--newtab-text-primary-color); + cursor: pointer; + + &:hover { + text-decoration: none; + } +} diff --git a/browser/components/newtab/content-src/lib/constants.js b/browser/components/newtab/content-src/lib/constants.mjs index 2c96160b4b..4f07a77e29 100644 --- a/browser/components/newtab/content-src/lib/constants.js +++ b/browser/components/newtab/content-src/lib/constants.mjs @@ -3,7 +3,7 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ export const IS_NEWTAB = - global.document && global.document.documentURI === "about:newtab"; + globalThis.document && globalThis.document.documentURI === "about:newtab"; export const NEWTAB_DARK_THEME = { ntp_background: { r: 42, diff --git a/browser/components/newtab/content-src/lib/detect-user-session-start.js b/browser/components/newtab/content-src/lib/detect-user-session-start.mjs index 43aa388967..d4c36efd4a 100644 --- a/browser/components/newtab/content-src/lib/detect-user-session-start.js +++ b/browser/components/newtab/content-src/lib/detect-user-session-start.mjs @@ -5,8 +5,8 @@ import { actionCreators as ac, actionTypes as at, -} from "common/Actions.sys.mjs"; -import { perfService as perfSvc } from "content-src/lib/perf-service"; +} from "../../common/Actions.mjs"; +import { perfService as perfSvc } from "./perf-service.mjs"; const VISIBLE = "visible"; const VISIBILITY_CHANGE_EVENT = "visibilitychange"; @@ -15,7 +15,7 @@ export class DetectUserSessionStart { constructor(store, options = {}) { this._store = store; // Overrides for testing - this.document = options.document || global.document; + this.document = options.document || globalThis.document; this._perfService = options.perfService || perfSvc; this._onVisibilityChange = this._onVisibilityChange.bind(this); } diff --git a/browser/components/newtab/content-src/lib/init-store.js b/browser/components/newtab/content-src/lib/init-store.mjs index f0ab2db86a..85b3b0b470 100644 --- a/browser/components/newtab/content-src/lib/init-store.js +++ b/browser/components/newtab/content-src/lib/init-store.mjs @@ -8,7 +8,10 @@ import { actionCreators as ac, actionTypes as at, actionUtils as au, -} from "common/Actions.sys.mjs"; +} from "../../common/Actions.mjs"; +// We disable import checking here as redux is installed via the npm packages +// at the newtab level, rather than in the top-level package.json. +// eslint-disable-next-line import/no-unresolved import { applyMiddleware, combineReducers, createStore } from "redux"; export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; @@ -117,12 +120,12 @@ export function initStore(reducers, initialState) { const store = createStore( mergeStateReducer(combineReducers(reducers)), initialState, - global.RPMAddMessageListener && + globalThis.RPMAddMessageListener && applyMiddleware(rehydrationMiddleware, messageMiddleware) ); - if (global.RPMAddMessageListener) { - global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { + if (globalThis.RPMAddMessageListener) { + globalThis.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { try { store.dispatch(msg.data); } catch (ex) { diff --git a/browser/components/newtab/content-src/lib/link-menu-options.js b/browser/components/newtab/content-src/lib/link-menu-options.mjs index 12e47259c1..f10a5e34c6 100644 --- a/browser/components/newtab/content-src/lib/link-menu-options.js +++ b/browser/components/newtab/content-src/lib/link-menu-options.mjs @@ -5,7 +5,7 @@ import { actionCreators as ac, actionTypes as at, -} from "common/Actions.sys.mjs"; +} from "../../common/Actions.mjs"; const _OpenInPrivateWindow = site => ({ id: "newtab-menu-open-new-private-window", diff --git a/browser/components/newtab/content-src/lib/perf-service.js b/browser/components/newtab/content-src/lib/perf-service.mjs index 6ea99ce877..25fc430726 100644 --- a/browser/components/newtab/content-src/lib/perf-service.js +++ b/browser/components/newtab/content-src/lib/perf-service.mjs @@ -2,8 +2,6 @@ * 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"; - let usablePerfObj = window.performance; export function _PerfService(options) { @@ -37,8 +35,8 @@ _PerfService.prototype = { * @param {String} type eg "mark" * @return {Array} Performance* objects */ - getEntriesByName: function getEntriesByName(name, type) { - return this._perf.getEntriesByName(name, type); + getEntriesByName: function getEntriesByName(entryName, type) { + return this._perf.getEntriesByName(entryName, type); }, /** @@ -89,11 +87,11 @@ _PerfService.prototype = { * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303) * for more info. */ - getMostRecentAbsMarkStartByName(name) { - let entries = this.getEntriesByName(name, "mark"); + getMostRecentAbsMarkStartByName(entryName) { + let entries = this.getEntriesByName(entryName, "mark"); if (!entries.length) { - throw new Error(`No marks with the name ${name}`); + throw new Error(`No marks with the name ${entryName}`); } let mostRecentEntry = entries[entries.length - 1]; diff --git a/browser/components/newtab/content-src/lib/screenshot-utils.js b/browser/components/newtab/content-src/lib/screenshot-utils.mjs index 7ea93f12ae..2d1342be4f 100644 --- a/browser/components/newtab/content-src/lib/screenshot-utils.js +++ b/browser/components/newtab/content-src/lib/screenshot-utils.mjs @@ -30,7 +30,7 @@ export const ScreenshotUtils = { } if (this.isBlob(false, remoteImage)) { return { - url: global.URL.createObjectURL(remoteImage.data), + url: globalThis.URL.createObjectURL(remoteImage.data), path: remoteImage.path, }; } @@ -41,7 +41,7 @@ export const ScreenshotUtils = { // This should always be called with a local image and not a remote image. maybeRevokeBlobObjectURL(localImage) { if (this.isBlob(true, localImage)) { - global.URL.revokeObjectURL(localImage.url); + globalThis.URL.revokeObjectURL(localImage.url); } }, diff --git a/browser/components/newtab/content-src/lib/selectLayoutRender.js b/browser/components/newtab/content-src/lib/selectLayoutRender.mjs index 8ef4dd428f..8ef4dd428f 100644 --- a/browser/components/newtab/content-src/lib/selectLayoutRender.js +++ b/browser/components/newtab/content-src/lib/selectLayoutRender.mjs diff --git a/browser/components/newtab/content-src/styles/_activity-stream.scss b/browser/components/newtab/content-src/styles/_activity-stream.scss index 88ed530b6a..d2e66667b2 100644 --- a/browser/components/newtab/content-src/styles/_activity-stream.scss +++ b/browser/components/newtab/content-src/styles/_activity-stream.scss @@ -21,6 +21,17 @@ body { background-color: var(--newtab-background-color); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif; font-size: 16px; + + // rules for HNT wallpapers + background-repeat: no-repeat; + background-size: cover; + background-position: center; + background-attachment: fixed; + background-image: var(--newtab-wallpaper-light, ''); + + @media (prefers-color-scheme: dark) { + background-image: var(--newtab-wallpaper-dark, ''); + } } .no-scroll { @@ -137,6 +148,7 @@ input { @import '../components/ContextMenu/ContextMenu'; @import '../components/ConfirmDialog/ConfirmDialog'; @import '../components/CustomizeMenu/CustomizeMenu'; +@import '../components/WallpapersSection/WallpapersSection'; @import '../components/Card/Card'; @import '../components/CollapsibleSection/CollapsibleSection'; @import '../components/DiscoveryStreamAdmin/DiscoveryStreamAdmin'; diff --git a/browser/components/newtab/css/activity-stream-linux.css b/browser/components/newtab/css/activity-stream-linux.css index 8773159737..131ffac535 100644 --- a/browser/components/newtab/css/activity-stream-linux.css +++ b/browser/components/newtab/css/activity-stream-linux.css @@ -276,6 +276,16 @@ body { background-color: var(--newtab-background-color); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif; font-size: 16px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + background-attachment: fixed; + background-image: var(--newtab-wallpaper-light, ""); +} +@media (prefers-color-scheme: dark) { + body { + background-image: var(--newtab-wallpaper-dark, ""); + } } .no-scroll { @@ -405,10 +415,16 @@ input[type=text], input[type=search] { } main { - margin: auto; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; width: 274px; padding: 0; } +main .vertical-center-wrapper { + margin: auto 0; +} main section { margin-bottom: 20px; position: relative; @@ -489,6 +505,29 @@ main section { background-color: var(--newtab-element-active-color); } +.wallpaper-attribution { + padding: 0 25px; + font-size: 14px; +} +.wallpaper-attribution.theme-light { + display: inline-block; +} +[lwt-newtab-brighttext] .wallpaper-attribution.theme-light { + display: none; +} +.wallpaper-attribution.theme-dark { + display: none; +} +[lwt-newtab-brighttext] .wallpaper-attribution.theme-dark { + display: inline-block; +} +.wallpaper-attribution a { + color: var(--newtab-element-color); +} +.wallpaper-attribution a:hover { + text-decoration: none; +} + .as-error-fallback { align-items: center; border-radius: 3px; @@ -1694,6 +1733,9 @@ main section { grid-row-gap: 32px; padding: 0 16px; } +.home-section .wallpapers-section h2 { + font-size: inherit; +} .home-section .section moz-toggle { margin-bottom: 10px; } @@ -1830,6 +1872,112 @@ main section { box-shadow: 0 0 0 2px var(--newtab-primary-action-background-dimmed); } +.wallpaper-list { + display: grid; + gap: 16px; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: 86px; + margin: 16px 0; + padding: 0; + border: none; +} +.wallpaper-list .wallpaper-input.theme-light, +.wallpaper-list .sr-only.theme-light { + display: inline-block; +} +[lwt-newtab-brighttext] .wallpaper-list .wallpaper-input.theme-light, +[lwt-newtab-brighttext] .wallpaper-list .sr-only.theme-light { + display: none; +} +.wallpaper-list .wallpaper-input.theme-dark, +.wallpaper-list .sr-only.theme-dark { + display: none; +} +[lwt-newtab-brighttext] .wallpaper-list .wallpaper-input.theme-dark, +[lwt-newtab-brighttext] .wallpaper-list .sr-only.theme-dark { + display: inline-block; +} +.wallpaper-list .wallpaper-input { + appearance: none; + margin: 0; + padding: 0; + height: 86px; + width: 100%; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.2); + border-radius: 8px; + background-clip: content-box; + background-repeat: no-repeat; + background-size: cover; + cursor: pointer; + outline: 2px solid transparent; +} +.wallpaper-list .wallpaper-input.dark-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-landscape.avif"); +} +.wallpaper-list .wallpaper-input.dark-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-color.avif"); +} +.wallpaper-list .wallpaper-input.dark-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-mountain.avif"); +} +.wallpaper-list .wallpaper-input.dark-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-panda.avif"); +} +.wallpaper-list .wallpaper-input.dark-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-sky.avif"); +} +.wallpaper-list .wallpaper-input.dark-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-beach.avif"); +} +.wallpaper-list .wallpaper-input.light-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-beach.avif"); +} +.wallpaper-list .wallpaper-input.light-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-color.avif"); +} +.wallpaper-list .wallpaper-input.light-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-landscape.avif"); +} +.wallpaper-list .wallpaper-input.light-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-mountain.avif"); +} +.wallpaper-list .wallpaper-input.light-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-panda.avif"); +} +.wallpaper-list .wallpaper-input.light-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-sky.avif"); +} +.wallpaper-list .wallpaper-input:checked { + outline-color: var(--color-accent-primary-active); +} +.wallpaper-list .wallpaper-input:focus-visible { + outline-color: var(--newtab-primary-action-background); +} +.wallpaper-list .wallpaper-input:hover { + filter: brightness(55%); + outline-color: transparent; +} +.wallpaper-list .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + pointer-events: none; +} + +.wallpapers-reset { + background: none; + border: none; + text-decoration: underline; + margin-inline: auto; + display: block; + font-size: var(--font-size-small); + color: var(--newtab-text-primary-color); + cursor: pointer; +} +.wallpapers-reset:hover { + text-decoration: none; +} + /* stylelint-disable max-nesting-depth */ .card-outer { background: var(--newtab-background-color-secondary); diff --git a/browser/components/newtab/css/activity-stream-mac.css b/browser/components/newtab/css/activity-stream-mac.css index 87b942818a..416209d511 100644 --- a/browser/components/newtab/css/activity-stream-mac.css +++ b/browser/components/newtab/css/activity-stream-mac.css @@ -280,6 +280,16 @@ body { background-color: var(--newtab-background-color); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif; font-size: 16px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + background-attachment: fixed; + background-image: var(--newtab-wallpaper-light, ""); +} +@media (prefers-color-scheme: dark) { + body { + background-image: var(--newtab-wallpaper-dark, ""); + } } .no-scroll { @@ -409,10 +419,16 @@ input[type=text], input[type=search] { } main { - margin: auto; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; width: 274px; padding: 0; } +main .vertical-center-wrapper { + margin: auto 0; +} main section { margin-bottom: 20px; position: relative; @@ -493,6 +509,29 @@ main section { background-color: var(--newtab-element-active-color); } +.wallpaper-attribution { + padding: 0 25px; + font-size: 14px; +} +.wallpaper-attribution.theme-light { + display: inline-block; +} +[lwt-newtab-brighttext] .wallpaper-attribution.theme-light { + display: none; +} +.wallpaper-attribution.theme-dark { + display: none; +} +[lwt-newtab-brighttext] .wallpaper-attribution.theme-dark { + display: inline-block; +} +.wallpaper-attribution a { + color: var(--newtab-element-color); +} +.wallpaper-attribution a:hover { + text-decoration: none; +} + .as-error-fallback { align-items: center; border-radius: 3px; @@ -1698,6 +1737,9 @@ main section { grid-row-gap: 32px; padding: 0 16px; } +.home-section .wallpapers-section h2 { + font-size: inherit; +} .home-section .section moz-toggle { margin-bottom: 10px; } @@ -1834,6 +1876,112 @@ main section { box-shadow: 0 0 0 2px var(--newtab-primary-action-background-dimmed); } +.wallpaper-list { + display: grid; + gap: 16px; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: 86px; + margin: 16px 0; + padding: 0; + border: none; +} +.wallpaper-list .wallpaper-input.theme-light, +.wallpaper-list .sr-only.theme-light { + display: inline-block; +} +[lwt-newtab-brighttext] .wallpaper-list .wallpaper-input.theme-light, +[lwt-newtab-brighttext] .wallpaper-list .sr-only.theme-light { + display: none; +} +.wallpaper-list .wallpaper-input.theme-dark, +.wallpaper-list .sr-only.theme-dark { + display: none; +} +[lwt-newtab-brighttext] .wallpaper-list .wallpaper-input.theme-dark, +[lwt-newtab-brighttext] .wallpaper-list .sr-only.theme-dark { + display: inline-block; +} +.wallpaper-list .wallpaper-input { + appearance: none; + margin: 0; + padding: 0; + height: 86px; + width: 100%; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.2); + border-radius: 8px; + background-clip: content-box; + background-repeat: no-repeat; + background-size: cover; + cursor: pointer; + outline: 2px solid transparent; +} +.wallpaper-list .wallpaper-input.dark-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-landscape.avif"); +} +.wallpaper-list .wallpaper-input.dark-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-color.avif"); +} +.wallpaper-list .wallpaper-input.dark-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-mountain.avif"); +} +.wallpaper-list .wallpaper-input.dark-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-panda.avif"); +} +.wallpaper-list .wallpaper-input.dark-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-sky.avif"); +} +.wallpaper-list .wallpaper-input.dark-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-beach.avif"); +} +.wallpaper-list .wallpaper-input.light-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-beach.avif"); +} +.wallpaper-list .wallpaper-input.light-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-color.avif"); +} +.wallpaper-list .wallpaper-input.light-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-landscape.avif"); +} +.wallpaper-list .wallpaper-input.light-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-mountain.avif"); +} +.wallpaper-list .wallpaper-input.light-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-panda.avif"); +} +.wallpaper-list .wallpaper-input.light-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-sky.avif"); +} +.wallpaper-list .wallpaper-input:checked { + outline-color: var(--color-accent-primary-active); +} +.wallpaper-list .wallpaper-input:focus-visible { + outline-color: var(--newtab-primary-action-background); +} +.wallpaper-list .wallpaper-input:hover { + filter: brightness(55%); + outline-color: transparent; +} +.wallpaper-list .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + pointer-events: none; +} + +.wallpapers-reset { + background: none; + border: none; + text-decoration: underline; + margin-inline: auto; + display: block; + font-size: var(--font-size-small); + color: var(--newtab-text-primary-color); + cursor: pointer; +} +.wallpapers-reset:hover { + text-decoration: none; +} + /* stylelint-disable max-nesting-depth */ .card-outer { background: var(--newtab-background-color-secondary); diff --git a/browser/components/newtab/css/activity-stream-windows.css b/browser/components/newtab/css/activity-stream-windows.css index 25370fdf19..f6118e3c18 100644 --- a/browser/components/newtab/css/activity-stream-windows.css +++ b/browser/components/newtab/css/activity-stream-windows.css @@ -276,6 +276,16 @@ body { background-color: var(--newtab-background-color); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif; font-size: 16px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + background-attachment: fixed; + background-image: var(--newtab-wallpaper-light, ""); +} +@media (prefers-color-scheme: dark) { + body { + background-image: var(--newtab-wallpaper-dark, ""); + } } .no-scroll { @@ -405,10 +415,16 @@ input[type=text], input[type=search] { } main { - margin: auto; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; width: 274px; padding: 0; } +main .vertical-center-wrapper { + margin: auto 0; +} main section { margin-bottom: 20px; position: relative; @@ -489,6 +505,29 @@ main section { background-color: var(--newtab-element-active-color); } +.wallpaper-attribution { + padding: 0 25px; + font-size: 14px; +} +.wallpaper-attribution.theme-light { + display: inline-block; +} +[lwt-newtab-brighttext] .wallpaper-attribution.theme-light { + display: none; +} +.wallpaper-attribution.theme-dark { + display: none; +} +[lwt-newtab-brighttext] .wallpaper-attribution.theme-dark { + display: inline-block; +} +.wallpaper-attribution a { + color: var(--newtab-element-color); +} +.wallpaper-attribution a:hover { + text-decoration: none; +} + .as-error-fallback { align-items: center; border-radius: 3px; @@ -1694,6 +1733,9 @@ main section { grid-row-gap: 32px; padding: 0 16px; } +.home-section .wallpapers-section h2 { + font-size: inherit; +} .home-section .section moz-toggle { margin-bottom: 10px; } @@ -1830,6 +1872,112 @@ main section { box-shadow: 0 0 0 2px var(--newtab-primary-action-background-dimmed); } +.wallpaper-list { + display: grid; + gap: 16px; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: 86px; + margin: 16px 0; + padding: 0; + border: none; +} +.wallpaper-list .wallpaper-input.theme-light, +.wallpaper-list .sr-only.theme-light { + display: inline-block; +} +[lwt-newtab-brighttext] .wallpaper-list .wallpaper-input.theme-light, +[lwt-newtab-brighttext] .wallpaper-list .sr-only.theme-light { + display: none; +} +.wallpaper-list .wallpaper-input.theme-dark, +.wallpaper-list .sr-only.theme-dark { + display: none; +} +[lwt-newtab-brighttext] .wallpaper-list .wallpaper-input.theme-dark, +[lwt-newtab-brighttext] .wallpaper-list .sr-only.theme-dark { + display: inline-block; +} +.wallpaper-list .wallpaper-input { + appearance: none; + margin: 0; + padding: 0; + height: 86px; + width: 100%; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.2); + border-radius: 8px; + background-clip: content-box; + background-repeat: no-repeat; + background-size: cover; + cursor: pointer; + outline: 2px solid transparent; +} +.wallpaper-list .wallpaper-input.dark-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-landscape.avif"); +} +.wallpaper-list .wallpaper-input.dark-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-color.avif"); +} +.wallpaper-list .wallpaper-input.dark-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-mountain.avif"); +} +.wallpaper-list .wallpaper-input.dark-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-panda.avif"); +} +.wallpaper-list .wallpaper-input.dark-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-sky.avif"); +} +.wallpaper-list .wallpaper-input.dark-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/dark-beach.avif"); +} +.wallpaper-list .wallpaper-input.light-beach { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-beach.avif"); +} +.wallpaper-list .wallpaper-input.light-color { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-color.avif"); +} +.wallpaper-list .wallpaper-input.light-landscape { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-landscape.avif"); +} +.wallpaper-list .wallpaper-input.light-mountain { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-mountain.avif"); +} +.wallpaper-list .wallpaper-input.light-panda { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-panda.avif"); +} +.wallpaper-list .wallpaper-input.light-sky { + background-image: url("chrome://activity-stream/content/data/content/assets/wallpapers/light-sky.avif"); +} +.wallpaper-list .wallpaper-input:checked { + outline-color: var(--color-accent-primary-active); +} +.wallpaper-list .wallpaper-input:focus-visible { + outline-color: var(--newtab-primary-action-background); +} +.wallpaper-list .wallpaper-input:hover { + filter: brightness(55%); + outline-color: transparent; +} +.wallpaper-list .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + pointer-events: none; +} + +.wallpapers-reset { + background: none; + border: none; + text-decoration: underline; + margin-inline: auto; + display: block; + font-size: var(--font-size-small); + color: var(--newtab-text-primary-color); + cursor: pointer; +} +.wallpapers-reset:hover { + text-decoration: none; +} + /* stylelint-disable max-nesting-depth */ .card-outer { background: var(--newtab-background-color-secondary); diff --git a/browser/components/newtab/data/content/activity-stream.bundle.js b/browser/components/newtab/data/content/activity-stream.bundle.js index 8904ba87d1..395e8c5bb3 100644 --- a/browser/components/newtab/data/content/activity-stream.bundle.js +++ b/browser/components/newtab/data/content/activity-stream.bundle.js @@ -70,11 +70,13 @@ __webpack_require__.d(__webpack_exports__, { renderWithoutState: () => (/* binding */ renderWithoutState) }); -;// CONCATENATED MODULE: ./common/Actions.sys.mjs +;// CONCATENATED MODULE: ./common/Actions.mjs /* 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 accessed from both content and system scopes. + const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; const PRELOAD_MESSAGE_TYPE = "ActivityStream:PreloadedBrowser"; @@ -231,6 +233,7 @@ for (const type of [ "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", + "WALLPAPERS_SET", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { @@ -444,8 +447,11 @@ function DiscoveryStreamLoadedContent( return importContext === UI_CODE ? AlsoToMain(action) : action; } -function SetPref(name, value, importContext = globalImportContext) { - const action = { type: actionTypes.SET_PREF, data: { name, value } }; +function SetPref(prefName, value, importContext = globalImportContext) { + const action = { + type: actionTypes.SET_PREF, + data: { name: prefName, value }, + }; return importContext === UI_CODE ? AlsoToMain(action) : action; } @@ -545,19 +551,19 @@ class SimpleHashRouter extends (external_React_default()).PureComponent { super(props); this.onHashChange = this.onHashChange.bind(this); this.state = { - hash: __webpack_require__.g.location.hash + hash: globalThis.location.hash }; } onHashChange() { this.setState({ - hash: __webpack_require__.g.location.hash + hash: globalThis.location.hash }); } componentWillMount() { - __webpack_require__.g.addEventListener("hashchange", this.onHashChange); + globalThis.addEventListener("hashchange", this.onHashChange); } componentWillUnmount() { - __webpack_require__.g.removeEventListener("hashchange", this.onHashChange); + globalThis.removeEventListener("hashchange", this.onHashChange); } render() { const [, ...routes] = this.state.hash.split("-"); @@ -882,9 +888,9 @@ class CollapseToggle extends (external_React_default()).PureComponent { } setBodyClass() { if (this.renderAdmin && !this.state.collapsed) { - __webpack_require__.g.document.body.classList.add("no-scroll"); + globalThis.document.body.classList.add("no-scroll"); } else { - __webpack_require__.g.document.body.classList.remove("no-scroll"); + globalThis.document.body.classList.remove("no-scroll"); } } componentDidMount() { @@ -894,7 +900,7 @@ class CollapseToggle extends (external_React_default()).PureComponent { this.setBodyClass(); } componentWillUnmount() { - __webpack_require__.g.document.body.classList.remove("no-scroll"); + globalThis.document.body.classList.remove("no-scroll"); } render() { const { @@ -1262,11 +1268,11 @@ class ContextMenu extends (external_React_default()).PureComponent { componentDidMount() { this.onShow(); setTimeout(() => { - __webpack_require__.g.addEventListener("click", this.hideContext); + globalThis.addEventListener("click", this.hideContext); }, 0); } componentWillUnmount() { - __webpack_require__.g.removeEventListener("click", this.hideContext); + globalThis.removeEventListener("click", this.hideContext); } onClick(event) { // Eat all clicks on the context menu so they don't bubble up to window. @@ -1392,23 +1398,21 @@ class _ContextMenuItem extends (external_React_default()).PureComponent { const ContextMenuItem = (0,external_ReactRedux_namespaceObject.connect)(state => ({ Prefs: state.Prefs }))(_ContextMenuItem); -;// CONCATENATED MODULE: ./content-src/lib/link-menu-options.js +;// CONCATENATED MODULE: ./content-src/lib/link-menu-options.mjs /* 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/. */ + const _OpenInPrivateWindow = site => ({ id: "newtab-menu-open-new-private-window", icon: "new-window-private", action: actionCreators.OnlyToMain({ type: actionTypes.OPEN_PRIVATE_WINDOW, - data: { - url: site.url, - referrer: site.referrer - } + data: { url: site.url, referrer: site.referrer }, }), - userEvent: "OPEN_PRIVATE_WINDOW" + userEvent: "OPEN_PRIVATE_WINDOW", }); /** @@ -1417,19 +1421,15 @@ const _OpenInPrivateWindow = site => ({ * the index of the site. */ const LinkMenuOptions = { - Separator: () => ({ - type: "separator" - }), - EmptyItem: () => ({ - type: "empty" - }), + Separator: () => ({ type: "separator" }), + EmptyItem: () => ({ type: "empty" }), ShowPrivacyInfo: () => ({ id: "newtab-menu-show-privacy-info", icon: "info", action: { - type: actionTypes.SHOW_PRIVACY_INFO + type: actionTypes.SHOW_PRIVACY_INFO, }, - userEvent: "SHOW_PRIVACY_INFO" + userEvent: "SHOW_PRIVACY_INFO", }), AboutSponsored: site => ({ id: "newtab-menu-show-privacy-info", @@ -1439,32 +1439,28 @@ const LinkMenuOptions = { data: { advertiser_name: (site.label || site.hostname).toLocaleLowerCase(), position: site.sponsored_position, - tile_id: site.sponsored_tile_id - } + tile_id: site.sponsored_tile_id, + }, }), - userEvent: "TOPSITE_SPONSOR_INFO" + userEvent: "TOPSITE_SPONSOR_INFO", }), RemoveBookmark: site => ({ id: "newtab-menu-remove-bookmark", icon: "bookmark-added", action: actionCreators.AlsoToMain({ type: actionTypes.DELETE_BOOKMARK_BY_ID, - data: site.bookmarkGuid + data: site.bookmarkGuid, }), - userEvent: "BOOKMARK_DELETE" + userEvent: "BOOKMARK_DELETE", }), AddBookmark: site => ({ id: "newtab-menu-bookmark", icon: "bookmark-hollow", action: actionCreators.AlsoToMain({ type: actionTypes.BOOKMARK_URL, - data: { - url: site.url, - title: site.title, - type: site.type - } + data: { url: site.url, title: site.title, type: site.type }, }), - userEvent: "BOOKMARK_ADD" + userEvent: "BOOKMARK_ADD", }), OpenInNewWindow: site => ({ id: "newtab-menu-open-new-window", @@ -1475,10 +1471,10 @@ const LinkMenuOptions = { referrer: site.referrer, typedBonus: site.typedBonus, url: site.url, - sponsored_tile_id: site.sponsored_tile_id - } + sponsored_tile_id: site.sponsored_tile_id, + }, }), - userEvent: "OPEN_NEW_WINDOW" + userEvent: "OPEN_NEW_WINDOW", }), // This blocks the url for regular stories, // but also sends a message to DiscoveryStream with flight_id. @@ -1499,20 +1495,20 @@ const LinkMenuOptions = { pocket_id: site.pocket_id, // used by PlacesFeed and TopSitesFeed for sponsored top sites blocking. isSponsoredTopSite: site.sponsored_position, - ...(site.flight_id ? { - flight_id: site.flight_id - } : {}), + ...(site.flight_id ? { flight_id: site.flight_id } : {}), // If not sponsored, hostname could be anything (Cat3 Data!). // So only put in advertiser_name for sponsored topsites. - ...(site.sponsored_position ? { - advertiser_name: (site.label || site.hostname)?.toLocaleLowerCase() - } : {}), + ...(site.sponsored_position + ? { + advertiser_name: ( + site.label || site.hostname + )?.toLocaleLowerCase(), + } + : {}), position: pos, - ...(site.sponsored_tile_id ? { - tile_id: site.sponsored_tile_id - } : {}), - is_pocket_card: site.type === "CardGrid" - })) + ...(site.sponsored_tile_id ? { tile_id: site.sponsored_tile_id } : {}), + is_pocket_card: site.type === "CardGrid", + })), }), impression: actionCreators.ImpressionStats({ source: eventSource, @@ -1520,13 +1516,12 @@ const LinkMenuOptions = { tiles: tiles.map((site, index) => ({ id: site.guid, pos: pos + index, - ...(site.shim && site.shim.delete ? { - shim: site.shim.delete - } : {}) - })) + ...(site.shim && site.shim.delete ? { shim: site.shim.delete } : {}), + })), }), - userEvent: "BLOCK" + userEvent: "BLOCK", }), + // This is an option for web extentions which will result in remove items from // memory and notify the web extenion, rather than using the built-in block list. WebExtDismiss: (site, index, eventSource) => ({ @@ -1536,8 +1531,8 @@ const LinkMenuOptions = { action: actionCreators.WebExtEvent(actionTypes.WEBEXT_DISMISS, { source: eventSource, url: site.url, - action_position: index - }) + action_position: index, + }), }), DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({ id: "newtab-menu-delete-history", @@ -1545,77 +1540,74 @@ const LinkMenuOptions = { action: { type: actionTypes.DIALOG_OPEN, data: { - onConfirm: [actionCreators.AlsoToMain({ - type: actionTypes.DELETE_HISTORY_URL, - data: { - url: site.url, - pocket_id: site.pocket_id, - forceBlock: site.bookmarkGuid - } - }), actionCreators.UserEvent(Object.assign({ - event: "DELETE", - source: eventSource, - action_position: index - }, siteInfo))], + onConfirm: [ + actionCreators.AlsoToMain({ + type: actionTypes.DELETE_HISTORY_URL, + data: { + url: site.url, + pocket_id: site.pocket_id, + forceBlock: site.bookmarkGuid, + }, + }), + actionCreators.UserEvent( + Object.assign( + { event: "DELETE", source: eventSource, action_position: index }, + siteInfo + ) + ), + ], eventSource, - body_string_id: ["newtab-confirm-delete-history-p1", "newtab-confirm-delete-history-p2"], + body_string_id: [ + "newtab-confirm-delete-history-p1", + "newtab-confirm-delete-history-p2", + ], confirm_button_string_id: "newtab-topsites-delete-history-button", cancel_button_string_id: "newtab-topsites-cancel-button", - icon: "modal-delete" - } + icon: "modal-delete", + }, }, - userEvent: "DIALOG_OPEN" + userEvent: "DIALOG_OPEN", }), ShowFile: site => ({ id: "newtab-menu-show-file", icon: "search", action: actionCreators.OnlyToMain({ type: actionTypes.SHOW_DOWNLOAD_FILE, - data: { - url: site.url - } - }) + data: { url: site.url }, + }), }), OpenFile: site => ({ id: "newtab-menu-open-file", icon: "open-file", action: actionCreators.OnlyToMain({ type: actionTypes.OPEN_DOWNLOAD_FILE, - data: { - url: site.url - } - }) + data: { url: site.url }, + }), }), CopyDownloadLink: site => ({ id: "newtab-menu-copy-download-link", icon: "copy", action: actionCreators.OnlyToMain({ type: actionTypes.COPY_DOWNLOAD_LINK, - data: { - url: site.url - } - }) + data: { url: site.url }, + }), }), GoToDownloadPage: site => ({ id: "newtab-menu-go-to-download-page", icon: "download", action: actionCreators.OnlyToMain({ type: actionTypes.OPEN_LINK, - data: { - url: site.referrer - } + data: { url: site.referrer }, }), - disabled: !site.referrer + disabled: !site.referrer, }), RemoveDownload: site => ({ id: "newtab-menu-remove-download", icon: "delete", action: actionCreators.OnlyToMain({ type: actionTypes.REMOVE_DOWNLOAD_FILE, - data: { - url: site.url - } - }) + data: { url: site.url }, + }), }), PinTopSite: (site, index) => ({ id: "newtab-menu-pin", @@ -1624,23 +1616,19 @@ const LinkMenuOptions = { type: actionTypes.TOP_SITES_PIN, data: { site, - index - } + index, + }, }), - userEvent: "PIN" + userEvent: "PIN", }), UnpinTopSite: site => ({ id: "newtab-menu-unpin", icon: "unpin", action: actionCreators.AlsoToMain({ type: actionTypes.TOP_SITES_UNPIN, - data: { - site: { - url: site.url - } - } + data: { site: { url: site.url } }, }), - userEvent: "UNPIN" + userEvent: "UNPIN", }), SaveToPocket: (site, index, eventSource = "CARDGRID") => ({ id: "newtab-menu-save-to-pocket", @@ -1648,65 +1636,76 @@ const LinkMenuOptions = { action: actionCreators.AlsoToMain({ type: actionTypes.SAVE_TO_POCKET, data: { - site: { - url: site.url, - title: site.title - } - } + site: { url: site.url, title: site.title }, + }, }), impression: actionCreators.ImpressionStats({ source: eventSource, pocket: 0, - tiles: [{ - id: site.guid, - pos: index, - ...(site.shim && site.shim.save ? { - shim: site.shim.save - } : {}) - }] + tiles: [ + { + id: site.guid, + pos: index, + ...(site.shim && site.shim.save ? { shim: site.shim.save } : {}), + }, + ], }), - userEvent: "SAVE_TO_POCKET" + userEvent: "SAVE_TO_POCKET", }), DeleteFromPocket: site => ({ id: "newtab-menu-delete-pocket", icon: "pocket-delete", action: actionCreators.AlsoToMain({ type: actionTypes.DELETE_FROM_POCKET, - data: { - pocket_id: site.pocket_id - } + data: { pocket_id: site.pocket_id }, }), - userEvent: "DELETE_FROM_POCKET" + userEvent: "DELETE_FROM_POCKET", }), ArchiveFromPocket: site => ({ id: "newtab-menu-archive-pocket", icon: "pocket-archive", action: actionCreators.AlsoToMain({ type: actionTypes.ARCHIVE_FROM_POCKET, - data: { - pocket_id: site.pocket_id - } + data: { pocket_id: site.pocket_id }, }), - userEvent: "ARCHIVE_FROM_POCKET" + userEvent: "ARCHIVE_FROM_POCKET", }), EditTopSite: (site, index) => ({ id: "newtab-menu-edit-topsites", icon: "edit", action: { type: actionTypes.TOP_SITES_EDIT, - data: { - index - } - } + data: { index }, + }, }), - CheckBookmark: site => site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site), - CheckPinTopSite: (site, index) => site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index), - CheckSavedToPocket: (site, index, source) => site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index, source), - CheckBookmarkOrArchive: site => site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site), - CheckArchiveFromPocket: site => site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.EmptyItem(), - CheckDeleteFromPocket: site => site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.EmptyItem(), - OpenInPrivateWindow: (site, index, eventSource, isEnabled) => isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem() + CheckBookmark: site => + site.bookmarkGuid + ? LinkMenuOptions.RemoveBookmark(site) + : LinkMenuOptions.AddBookmark(site), + CheckPinTopSite: (site, index) => + site.isPinned + ? LinkMenuOptions.UnpinTopSite(site) + : LinkMenuOptions.PinTopSite(site, index), + CheckSavedToPocket: (site, index, source) => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.SaveToPocket(site, index, source), + CheckBookmarkOrArchive: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.CheckBookmark(site), + CheckArchiveFromPocket: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.EmptyItem(), + CheckDeleteFromPocket: site => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.EmptyItem(), + OpenInPrivateWindow: (site, index, eventSource, isEnabled) => + isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(), }; + ;// CONCATENATED MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx /* 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, @@ -1927,21 +1926,47 @@ class DSLinkMenu extends (external_React_default()).PureComponent { }))); } } -;// CONCATENATED MODULE: ./content-src/components/TopSites/TopSitesConstants.js +;// CONCATENATED MODULE: ./content-src/components/TopSites/TopSitesConstants.mjs /* 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/. */ const TOP_SITES_SOURCE = "TOP_SITES"; -const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"]; -const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = ["OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "ShowPrivacyInfo"]; -const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = ["OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "AboutSponsored"]; +const TOP_SITES_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", +]; +const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "ShowPrivacyInfo", +]; +const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "AboutSponsored", +]; // the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite -const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "BlockUrl"]; +const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "Separator", + "BlockUrl", +]; // minimum size necessary to show a rich icon instead of a screenshot const MIN_RICH_FAVICON_SIZE = 96; // minimum size necessary to show any icon const MIN_SMALL_FAVICON_SIZE = 16; + ;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx /* 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, @@ -2033,8 +2058,10 @@ class ImpressionStats_ImpressionStats extends (external_React_default()).PureCom ...(link.shim ? { shim: link.shim } : {}), - recommendation_id: link.recommendation_id - })) + recommendation_id: link.recommendation_id, + fetchTimestamp: link.fetchTimestamp + })), + firstVisibleTimestamp: this.props.firstVisibleTimestamp })); this.impressionCardGuids = cards.map(link => link.id); } @@ -2146,8 +2173,8 @@ class ImpressionStats_ImpressionStats extends (external_React_default()).PureCom } } ImpressionStats_ImpressionStats.defaultProps = { - IntersectionObserver: __webpack_require__.g.IntersectionObserver, - document: __webpack_require__.g.document, + IntersectionObserver: globalThis.IntersectionObserver, + document: globalThis.document, rows: [], source: "" }; @@ -2224,7 +2251,7 @@ class SafeAnchor extends (external_React_default()).PureComponent { }, this.props.children); } } -;// CONCATENATED MODULE: ./content-src/components/Card/types.js +;// CONCATENATED MODULE: ./content-src/components/Card/types.mjs /* 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/. */ @@ -2232,29 +2259,30 @@ class SafeAnchor extends (external_React_default()).PureComponent { const cardContextTypes = { history: { fluentID: "newtab-label-visited", - icon: "history-item" + icon: "history-item", }, removedBookmark: { fluentID: "newtab-label-removed-bookmark", - icon: "bookmark-removed" + icon: "bookmark-removed", }, bookmark: { fluentID: "newtab-label-bookmarked", - icon: "bookmark-added" + icon: "bookmark-added", }, trending: { fluentID: "newtab-label-recommended", - icon: "trending" + icon: "trending", }, pocket: { fluentID: "newtab-label-saved", - icon: "pocket" + icon: "pocket", }, download: { fluentID: "newtab-label-download", - icon: "download" - } + icon: "download", + }, }; + ;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/FeatureHighlight/FeatureHighlight.jsx /* 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, @@ -2710,7 +2738,9 @@ class _DSCard extends (external_React_default()).PureComponent { tile_id: this.props.id, ...(this.props.shim && this.props.shim.click ? { shim: this.props.shim.click - } : {}) + } : {}), + fetchTimestamp: this.props.fetchTimestamp, + firstVisibleTimestamp: this.props.firstVisibleTimestamp } })); this.props.dispatch(actionCreators.ImpressionStats({ @@ -2751,7 +2781,9 @@ class _DSCard extends (external_React_default()).PureComponent { tile_id: this.props.id, ...(this.props.shim && this.props.shim.save ? { shim: this.props.shim.save - } : {}) + } : {}), + fetchTimestamp: this.props.fetchTimestamp, + firstVisibleTimestamp: this.props.firstVisibleTimestamp } })); this.props.dispatch(actionCreators.ImpressionStats({ @@ -2913,10 +2945,12 @@ class _DSCard extends (external_React_default()).PureComponent { ...(this.props.shim && this.props.shim.impression ? { shim: this.props.shim.impression } : {}), - recommendation_id: this.props.recommendation_id + recommendation_id: this.props.recommendation_id, + fetchTimestamp: this.props.fetchTimestamp }], dispatch: this.props.dispatch, - source: this.props.type + source: this.props.type, + firstVisibleTimestamp: this.props.firstVisibleTimestamp })), ctaButtonVariant === "variant-b" && /*#__PURE__*/external_React_default().createElement("div", { className: "cta-header" }, "Shop Now"), /*#__PURE__*/external_React_default().createElement(DefaultMeta, { @@ -3273,7 +3307,7 @@ function DSSubHeader({ } function OnboardingExperience({ dispatch, - windowObj = __webpack_require__.g + windowObj = globalThis }) { const [dismissed, setDismissed] = (0,external_React_namespaceObject.useState)(false); const [maxHeight, setMaxHeight] = (0,external_React_namespaceObject.useState)(null); @@ -3549,6 +3583,7 @@ class _CardGrid extends (external_React_default()).PureComponent { url: rec.url, id: rec.id, shim: rec.shim, + fetchTimestamp: rec.fetchTimestamp, type: this.props.type, context: rec.context, sponsor: rec.sponsor, @@ -3564,7 +3599,8 @@ class _CardGrid extends (external_React_default()).PureComponent { ctaButtonSponsors: ctaButtonSponsors, ctaButtonVariant: ctaButtonVariant, spocMessageVariant: spocMessageVariant, - recommendation_id: rec.recommendation_id + recommendation_id: rec.recommendation_id, + firstVisibleTimestamp: this.props.firstVisibleTimestamp })); } if (widgets?.positions?.length && widgets?.data?.length) { @@ -4023,7 +4059,7 @@ class _CollapsibleSection extends (external_React_default()).PureComponent { } } _CollapsibleSection.defaultProps = { - document: __webpack_require__.g.document || { + document: globalThis.document || { addEventListener: () => {}, removeEventListener: () => {}, visibilityState: "hidden" @@ -4111,7 +4147,7 @@ class ModalOverlayWrapper extends (external_React_default()).PureComponent { } } ModalOverlayWrapper.defaultProps = { - document: __webpack_require__.g.document + document: globalThis.document }; ;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx /* This Source Code Form is subject to the terms of the Mozilla Public @@ -4443,7 +4479,7 @@ class DSTextPromo extends (external_React_default()).PureComponent { }))); } } -;// CONCATENATED MODULE: ./content-src/lib/screenshot-utils.js +;// CONCATENATED MODULE: ./content-src/lib/screenshot-utils.mjs /* 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/. */ @@ -4462,8 +4498,13 @@ class DSTextPromo extends (external_React_default()).PureComponent { */ const ScreenshotUtils = { isBlob(isLocal, image) { - return !!(image && image.path && (!isLocal && image.data || isLocal && image.url)); + return !!( + image && + image.path && + ((!isLocal && image.data) || (isLocal && image.url)) + ); }, + // This should always be called with a remote image and not a local image. createLocalImageObject(remoteImage) { if (!remoteImage) { @@ -4471,33 +4512,36 @@ const ScreenshotUtils = { } if (this.isBlob(false, remoteImage)) { return { - url: __webpack_require__.g.URL.createObjectURL(remoteImage.data), - path: remoteImage.path + url: globalThis.URL.createObjectURL(remoteImage.data), + path: remoteImage.path, }; } - return { - url: remoteImage - }; + return { url: remoteImage }; }, + // Revokes the object URL of the image if the local image is a blob. // This should always be called with a local image and not a remote image. maybeRevokeBlobObjectURL(localImage) { if (this.isBlob(true, localImage)) { - __webpack_require__.g.URL.revokeObjectURL(localImage.url); + globalThis.URL.revokeObjectURL(localImage.url); } }, + // Checks if remoteImage and localImage are the same. isRemoteImageLocal(localImage, remoteImage) { // Both remoteImage and localImage are present. if (remoteImage && localImage) { - return this.isBlob(false, remoteImage) ? localImage.path === remoteImage.path : localImage.url === remoteImage; + return this.isBlob(false, remoteImage) + ? localImage.path === remoteImage.path + : localImage.url === remoteImage; } // This will only handle the remaining three possible outcomes. // (i.e. everything except when both image and localImage are present) return !remoteImage && !localImage; - } + }, }; + ;// CONCATENATED MODULE: ./content-src/components/Card/Card.jsx /* 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, @@ -4822,14 +4866,13 @@ const PlaceholderCard = props => /*#__PURE__*/external_React_default().createEle placeholder: true, className: props.className }); -;// CONCATENATED MODULE: ./content-src/lib/perf-service.js +;// CONCATENATED MODULE: ./content-src/lib/perf-service.mjs /* 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/. */ - - let usablePerfObj = window.performance; + function _PerfService(options) { // For testing, so that we can use a fake Window.performance object with // known state. @@ -4839,6 +4882,7 @@ function _PerfService(options) { this._perf = usablePerfObj; } } + _PerfService.prototype = { /** * Calls the underlying mark() method on the appropriate Window.performance @@ -4851,6 +4895,7 @@ _PerfService.prototype = { mark: function mark(str) { this._perf.mark(str); }, + /** * Calls the underlying getEntriesByName on the appropriate Window.performance * object. @@ -4859,9 +4904,10 @@ _PerfService.prototype = { * @param {String} type eg "mark" * @return {Array} Performance* objects */ - getEntriesByName: function getEntriesByName(name, type) { - return this._perf.getEntriesByName(name, type); + getEntriesByName: function getEntriesByName(entryName, type) { + return this._perf.getEntriesByName(entryName, type); }, + /** * The timeOrigin property from the appropriate performance object. * Used to ensure that timestamps from the add-on code and the content code @@ -4880,6 +4926,7 @@ _PerfService.prototype = { get timeOrigin() { return this._perf.timeOrigin; }, + /** * Returns the "absolute" version of performance.now(), i.e. one that * should ([bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406) @@ -4890,6 +4937,7 @@ _PerfService.prototype = { absNow: function absNow() { return this.timeOrigin + this._perf.now(); }, + /** * This returns the absolute startTime from the most recent performance.mark() * with the given name. @@ -4908,16 +4956,20 @@ _PerfService.prototype = { * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303) * for more info. */ - getMostRecentAbsMarkStartByName(name) { - let entries = this.getEntriesByName(name, "mark"); + getMostRecentAbsMarkStartByName(entryName) { + let entries = this.getEntriesByName(entryName, "mark"); + if (!entries.length) { - throw new Error(`No marks with the name ${name}`); + throw new Error(`No marks with the name ${entryName}`); } + let mostRecentEntry = entries[entries.length - 1]; return this._perf.timeOrigin + mostRecentEntry.startTime; - } + }, }; + const perfService = new _PerfService(); + ;// CONCATENATED MODULE: ./content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx /* 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, @@ -5479,6 +5531,9 @@ const INITIAL_STATE = { // Hide the search box after handing off to AwesomeBar and user starts typing. hide: false, }, + Wallpapers: { + wallpaperList: [], + }, }; function App(prevState = INITIAL_STATE.App, action) { @@ -6219,6 +6274,15 @@ function Search(prevState = INITIAL_STATE.Search, action) { } } +function Wallpapers(prevState = INITIAL_STATE.Wallpapers, action) { + switch (action.type) { + case actionTypes.WALLPAPERS_SET: + return { wallpaperList: action.data }; + default: + return prevState; + } +} + const reducers = { TopSites, App, @@ -6230,6 +6294,7 @@ const reducers = { Personalization: Reducers_sys_Personalization, DiscoveryStream, Search, + Wallpapers, }; ;// CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteFormInput.jsx @@ -6448,8 +6513,8 @@ class TopSiteImpressionWrapper extends (external_React_default()).PureComponent } } TopSiteImpressionWrapper.defaultProps = { - IntersectionObserver: __webpack_require__.g.IntersectionObserver, - document: __webpack_require__.g.document, + IntersectionObserver: globalThis.IntersectionObserver, + document: globalThis.document, actionType: null, tile: null }; @@ -7601,7 +7666,7 @@ class _TopSites extends (external_React_default()).PureComponent { // We hide 2 sites per row when not in the wide layout. let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW; // $break-point-widest = 1072px (from _variables.scss) - if (!__webpack_require__.g.matchMedia(`(min-width: 1072px)`).matches) { + if (!globalThis.matchMedia(`(min-width: 1072px)`).matches) { sitesPerRow -= 2; } return this.props.TopSites.rows.slice(0, this.props.TopSitesRows * sitesPerRow); @@ -7733,7 +7798,7 @@ class Section extends (external_React_default()).PureComponent { props } = this; let cardsPerRow = CARDS_PER_ROW_DEFAULT; - if (props.compactCards && __webpack_require__.g.matchMedia(`(min-width: 1072px)`).matches) { + if (props.compactCards && globalThis.matchMedia(`(min-width: 1072px)`).matches) { // If the section has compact cards and the viewport is wide enough, we show // 4 columns instead of 3. // $break-point-widest = 1072px (from _variables.scss) @@ -7969,7 +8034,7 @@ class Section extends (external_React_default()).PureComponent { } } Section.defaultProps = { - document: __webpack_require__.g.document, + document: globalThis.document, rows: [], emptyState: {}, pref: {}, @@ -8188,20 +8253,13 @@ class SectionTitle extends (external_React_default()).PureComponent { }, subtitle) : null); } } -;// CONCATENATED MODULE: ./content-src/lib/selectLayoutRender.js +;// CONCATENATED MODULE: ./content-src/lib/selectLayoutRender.mjs /* 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/. */ -const selectLayoutRender = ({ - state = {}, - prefs = {} -}) => { - const { - layout, - feeds, - spocs - } = state; +const selectLayoutRender = ({ state = {}, prefs = {} }) => { + const { layout, feeds, spocs } = state; let spocIndexPlacementMap = {}; /* This function fills spoc positions on a per placement basis with available spocs. @@ -8210,8 +8268,16 @@ const selectLayoutRender = ({ * If it sees the same placement again, it remembers the previous spoc index, and continues. * If it sees a blocked spoc, it skips that position leaving in a regular story. */ - function fillSpocPositionsForPlacement(data, spocsConfig, spocsData, placementName) { - if (!spocIndexPlacementMap[placementName] && spocIndexPlacementMap[placementName] !== 0) { + function fillSpocPositionsForPlacement( + data, + spocsConfig, + spocsData, + placementName + ) { + if ( + !spocIndexPlacementMap[placementName] && + spocIndexPlacementMap[placementName] !== 0 + ) { spocIndexPlacementMap[placementName] = 0; } const results = [...data]; @@ -8234,107 +8300,154 @@ const selectLayoutRender = ({ results.splice(position.index, 0, spoc); } } + return results; } + const positions = {}; - const DS_COMPONENTS = ["Message", "TextPromo", "SectionTitle", "Signup", "Navigation", "CardGrid", "CollectionCardGrid", "HorizontalRule", "PrivacyLink"]; + const DS_COMPONENTS = [ + "Message", + "TextPromo", + "SectionTitle", + "Signup", + "Navigation", + "CardGrid", + "CollectionCardGrid", + "HorizontalRule", + "PrivacyLink", + ]; + const filterArray = []; + if (!prefs["feeds.topsites"]) { filterArray.push("TopSites"); } - const pocketEnabled = prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; if (!pocketEnabled) { filterArray.push(...DS_COMPONENTS); } + const placeholderComponent = component => { if (!component.feed) { // TODO we now need a placeholder for topsites and textPromo. return { ...component, data: { - spocs: [] - } + spocs: [], + }, }; } const data = { - recommendations: [] + recommendations: [], }; + let items = 0; if (component.properties && component.properties.items) { items = component.properties.items; } for (let i = 0; i < items; i++) { - data.recommendations.push({ - placeholder: true - }); + data.recommendations.push({ placeholder: true }); } - return { - ...component, - data - }; + + return { ...component, data }; }; // TODO update devtools to show placements const handleSpocs = (data, component) => { let result = [...data]; // Do we ever expect to possibly have a spoc. - if (component.spocs && component.spocs.positions && component.spocs.positions.length) { + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { const placement = component.placement || {}; const placementName = placement.name || "spocs"; const spocsData = spocs.data[placementName]; // We expect a spoc, spocs are loaded, and the server returned spocs. - if (spocs.loaded && spocsData && spocsData.items && spocsData.items.length) { - result = fillSpocPositionsForPlacement(result, component.spocs, spocsData.items, placementName); + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + result = fillSpocPositionsForPlacement( + result, + component.spocs, + spocsData.items, + placementName + ); } } return result; }; + const handleComponent = component => { - if (component.spocs && component.spocs.positions && component.spocs.positions.length) { + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { const placement = component.placement || {}; const placementName = placement.name || "spocs"; const spocsData = spocs.data[placementName]; - if (spocs.loaded && spocsData && spocsData.items && spocsData.items.length) { + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { return { ...component, data: { - spocs: spocsData.items.filter(spoc => spoc && !spocs.blocked.includes(spoc.url)).map((spoc, index) => ({ - ...spoc, - pos: index - })) - } + spocs: spocsData.items + .filter(spoc => spoc && !spocs.blocked.includes(spoc.url)) + .map((spoc, index) => ({ + ...spoc, + pos: index, + })), + }, }; } } return { ...component, data: { - spocs: [] - } + spocs: [], + }, }; }; + const handleComponentWithFeed = component => { positions[component.type] = positions[component.type] || 0; let data = { - recommendations: [] + recommendations: [], }; + const feed = feeds.data[component.feed.url]; if (feed && feed.data) { data = { ...feed.data, - recommendations: [...(feed.data.recommendations || [])] + recommendations: [...(feed.data.recommendations || [])], }; } + if (component && component.properties && component.properties.offset) { data = { ...data, - recommendations: data.recommendations.slice(component.properties.offset) + recommendations: data.recommendations.slice( + component.properties.offset + ), }; } + data = { ...data, - recommendations: handleSpocs(data.recommendations, component) + recommendations: handleSpocs(data.recommendations, component), }; + let items = 0; if (component.properties && component.properties.items) { items = Math.min(component.properties.items, data.recommendations.length); @@ -8346,27 +8459,36 @@ const selectLayoutRender = ({ for (let i = 0; i < items; i++) { data.recommendations[i] = { ...data.recommendations[i], - pos: positions[component.type]++ + pos: positions[component.type]++, }; } - return { - ...component, - data - }; + + return { ...component, data }; }; + const renderLayout = () => { const renderedLayoutArray = []; - for (const row of layout.filter(r => r.components.filter(c => !filterArray.includes(c.type)).length)) { + for (const row of layout.filter( + r => r.components.filter(c => !filterArray.includes(c.type)).length + )) { let components = []; renderedLayoutArray.push({ ...row, - components + components, }); - for (const component of row.components.filter(c => !filterArray.includes(c.type))) { + for (const component of row.components.filter( + c => !filterArray.includes(c.type) + )) { const spocsConfig = component.spocs; if (spocsConfig || component.feed) { // TODO make sure this still works for different loading cases. - if (component.feed && !feeds.data[component.feed.url] || spocsConfig && spocsConfig.positions && spocsConfig.positions.length && !spocs.loaded) { + if ( + (component.feed && !feeds.data[component.feed.url]) || + (spocsConfig && + spocsConfig.positions && + spocsConfig.positions.length && + !spocs.loaded) + ) { components.push(placeholderComponent(component)); return renderedLayoutArray; } @@ -8382,11 +8504,12 @@ const selectLayoutRender = ({ } return renderedLayoutArray; }; + const layoutRender = renderLayout(); - return { - layoutRender - }; + + return { layoutRender }; }; + ;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx /* 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, @@ -8528,19 +8651,21 @@ class _DiscoveryStreamBase extends (external_React_default()).PureComponent { privacyNoticeURL: component.properties.privacyNoticeURL }); case "CollectionCardGrid": - const { - DiscoveryStream - } = this.props; - return /*#__PURE__*/external_React_default().createElement(CollectionCardGrid, { - data: component.data, - feed: component.feed, - spocs: DiscoveryStream.spocs, - placement: component.placement, - type: component.type, - items: component.properties.items, - dismissible: this.props.DiscoveryStream.isCollectionDismissible, - dispatch: this.props.dispatch - }); + { + const { + DiscoveryStream + } = this.props; + return /*#__PURE__*/external_React_default().createElement(CollectionCardGrid, { + data: component.data, + feed: component.feed, + spocs: DiscoveryStream.spocs, + placement: component.placement, + type: component.type, + items: component.properties.items, + dismissible: this.props.DiscoveryStream.isCollectionDismissible, + dispatch: this.props.dispatch + }); + } case "CardGrid": return /*#__PURE__*/external_React_default().createElement(CardGrid, { title: component.header && component.header.title, @@ -8561,7 +8686,8 @@ class _DiscoveryStreamBase extends (external_React_default()).PureComponent { spocMessageVariant: component.properties.spocMessageVariant, editorsPicksHeader: component.properties.editorsPicksHeader, recentSavesEnabled: this.props.DiscoveryStream.recentSavesEnabled, - hideDescriptions: this.props.DiscoveryStream.hideDescriptions + hideDescriptions: this.props.DiscoveryStream.hideDescriptions, + firstVisibleTimestamp: this.props.firstVisibleTimestamp }); case "HorizontalRule": return /*#__PURE__*/external_React_default().createElement(HorizontalRule, null); @@ -8718,20 +8844,87 @@ const DiscoveryStreamBase = (0,external_ReactRedux_namespaceObject.connect)(stat DiscoveryStream: state.DiscoveryStream, Prefs: state.Prefs, Sections: state.Sections, - document: __webpack_require__.g.document, + document: globalThis.document, App: state.App }))(_DiscoveryStreamBase); -;// CONCATENATED MODULE: ./content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx +;// CONCATENATED MODULE: ./content-src/components/WallpapersSection/WallpapersSection.jsx /* 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/. */ -class BackgroundsSection extends (external_React_default()).PureComponent { + +class _WallpapersSection extends (external_React_default()).PureComponent { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleReset = this.handleReset.bind(this); + this.prefersHighContrastQuery = null; + this.prefersDarkQuery = null; + } + componentDidMount() { + this.prefersDarkQuery = globalThis.matchMedia("(prefers-color-scheme: dark)"); + } + handleChange(event) { + const { + id + } = event.target; + const prefs = this.props.Prefs.values; + const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; + this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, id); + // bug 1892095 + if (prefs["newtabWallpapers.wallpaper-dark"] === "" && colorMode === "light") { + this.props.setPref("newtabWallpapers.wallpaper-dark", id.replace("light", "dark")); + } + if (prefs["newtabWallpapers.wallpaper-light"] === "" && colorMode === "dark") { + this.props.setPref(`newtabWallpapers.wallpaper-light`, id.replace("dark", "light")); + } + } + handleReset() { + const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; + this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, ""); + } render() { - return /*#__PURE__*/external_React_default().createElement("div", null); + const { + wallpaperList + } = this.props.Wallpapers; + const { + activeWallpaper + } = this.props; + return /*#__PURE__*/external_React_default().createElement("div", null, /*#__PURE__*/external_React_default().createElement("fieldset", { + className: "wallpaper-list" + }, wallpaperList.map(({ + title, + theme, + fluent_id + }) => { + return /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, /*#__PURE__*/external_React_default().createElement("input", { + onChange: this.handleChange, + type: "radio", + name: `wallpaper-${title}`, + id: title, + value: title, + checked: title === activeWallpaper, + "aria-checked": title === activeWallpaper, + className: `wallpaper-input theme-${theme} ${title}` + }), /*#__PURE__*/external_React_default().createElement("label", { + htmlFor: title, + className: "sr-only", + "data-l10n-id": fluent_id + }, fluent_id)); + })), /*#__PURE__*/external_React_default().createElement("button", { + className: "wallpapers-reset", + onClick: this.handleReset, + "data-l10n-id": "newtab-wallpaper-reset" + })); } } +const WallpapersSection = (0,external_ReactRedux_namespaceObject.connect)(state => { + return { + Wallpapers: state.Wallpapers, + Prefs: state.Prefs + }; +})(_WallpapersSection); ;// CONCATENATED MODULE: ./content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx /* 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, @@ -8740,6 +8933,7 @@ class BackgroundsSection extends (external_React_default()).PureComponent { + class ContentSection extends (external_React_default()).PureComponent { constructor(props) { super(props); @@ -8818,7 +9012,10 @@ class ContentSection extends (external_React_default()).PureComponent { mayHaveSponsoredStories, mayHaveRecentSaves, openPreferences, - spocMessageVariant + spocMessageVariant, + wallpapersEnabled, + activeWallpaper, + setPref } = this.props; const { topSitesEnabled, @@ -8831,7 +9028,14 @@ class ContentSection extends (external_React_default()).PureComponent { } = enabledSections; return /*#__PURE__*/external_React_default().createElement("div", { className: "home-section" - }, /*#__PURE__*/external_React_default().createElement("div", { + }, wallpapersEnabled && /*#__PURE__*/external_React_default().createElement("div", { + className: "wallpapers-section" + }, /*#__PURE__*/external_React_default().createElement("h2", { + "data-l10n-id": "newtab-wallpaper-title" + }), /*#__PURE__*/external_React_default().createElement(WallpapersSection, { + setPref: setPref, + activeWallpaper: activeWallpaper + })), /*#__PURE__*/external_React_default().createElement("div", { id: "shortcuts-section", className: "section" }, /*#__PURE__*/external_React_default().createElement("moz-toggle", { @@ -8979,7 +9183,6 @@ class ContentSection extends (external_React_default()).PureComponent { - class _CustomizeMenu extends (external_React_default()).PureComponent { constructor(props) { super(props); @@ -9023,10 +9226,12 @@ class _CustomizeMenu extends (external_React_default()).PureComponent { className: "close-button", "data-l10n-id": "newtab-custom-close-button", ref: c => this.closeButton = c - }), /*#__PURE__*/external_React_default().createElement(BackgroundsSection, null), /*#__PURE__*/external_React_default().createElement(ContentSection, { + }), /*#__PURE__*/external_React_default().createElement(ContentSection, { openPreferences: this.props.openPreferences, setPref: this.props.setPref, enabledSections: this.props.enabledSections, + wallpapersEnabled: this.props.wallpapersEnabled, + activeWallpaper: this.props.activeWallpaper, pocketRegion: this.props.pocketRegion, mayHaveSponsoredTopSites: this.props.mayHaveSponsoredTopSites, mayHaveSponsoredStories: this.props.mayHaveSponsoredStories, @@ -9039,44 +9244,46 @@ class _CustomizeMenu extends (external_React_default()).PureComponent { const CustomizeMenu = (0,external_ReactRedux_namespaceObject.connect)(state => ({ DiscoveryStream: state.DiscoveryStream }))(_CustomizeMenu); -;// CONCATENATED MODULE: ./content-src/lib/constants.js +;// CONCATENATED MODULE: ./content-src/lib/constants.mjs /* 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/. */ -const IS_NEWTAB = __webpack_require__.g.document && __webpack_require__.g.document.documentURI === "about:newtab"; +const IS_NEWTAB = + globalThis.document && globalThis.document.documentURI === "about:newtab"; const NEWTAB_DARK_THEME = { ntp_background: { r: 42, g: 42, b: 46, - a: 1 + a: 1, }, ntp_card_background: { r: 66, g: 65, b: 77, - a: 1 + a: 1, }, ntp_text: { r: 249, g: 249, b: 250, - a: 1 + a: 1, }, sidebar: { r: 56, g: 56, b: 61, - a: 1 + a: 1, }, sidebar_text: { r: 249, g: 249, b: 250, - a: 1 - } + a: 1, + }, }; + ;// CONCATENATED MODULE: ./content-src/components/Search/Search.jsx /* 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, @@ -9258,6 +9465,8 @@ function Base_extends() { Base_extends = Object.assign ? Object.assign.bind() : +const Base_VISIBLE = "visible"; +const Base_VISIBILITY_CHANGE_EVENT = "visibilitychange"; const PrefsButton = ({ onClick, icon @@ -9306,7 +9515,7 @@ class _Base extends (external_React_default()).PureComponent { // If we skipped the about:welcome overlay and removed the CSS classes // we don't want to add them back to the Activity Stream view document.body.classList.contains("inline-onboarding") ? "inline-onboarding" : ""].filter(v => v).join(" "); - __webpack_require__.g.document.body.className = bodyClassName; + globalThis.document.body.className = bodyClassName; } render() { const { @@ -9337,17 +9546,55 @@ class BaseContent extends (external_React_default()).PureComponent { this.handleOnKeyDown = this.handleOnKeyDown.bind(this); this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5); this.setPref = this.setPref.bind(this); + this.updateWallpaper = this.updateWallpaper.bind(this); + this.prefersDarkQuery = null; + this.handleColorModeChange = this.handleColorModeChange.bind(this); this.state = { - fixedSearch: false + fixedSearch: false, + firstVisibleTimestamp: null, + colorMode: "" }; } + setFirstVisibleTimestamp() { + if (!this.state.firstVisibleTimestamp) { + this.setState({ + firstVisibleTimestamp: Date.now() + }); + } + } componentDidMount() { __webpack_require__.g.addEventListener("scroll", this.onWindowScroll); __webpack_require__.g.addEventListener("keydown", this.handleOnKeyDown); + if (this.props.document.visibilityState === Base_VISIBLE) { + this.setFirstVisibleTimestamp(); + } else { + this._onVisibilityChange = () => { + if (this.props.document.visibilityState === Base_VISIBLE) { + this.setFirstVisibleTimestamp(); + this.props.document.removeEventListener(Base_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + this._onVisibilityChange = null; + } + }; + this.props.document.addEventListener(Base_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + } + // track change event to dark/light mode + this.prefersDarkQuery = globalThis.matchMedia("(prefers-color-scheme: dark)"); + this.prefersDarkQuery.addEventListener("change", this.handleColorModeChange); + this.handleColorModeChange(); + } + handleColorModeChange() { + const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; + this.setState({ + colorMode + }); } componentWillUnmount() { + this.prefersDarkQuery?.removeEventListener("change", this.handleColorModeChange); __webpack_require__.g.removeEventListener("scroll", this.onWindowScroll); __webpack_require__.g.removeEventListener("keydown", this.handleOnKeyDown); + if (this._onVisibilityChange) { + this.props.document.removeEventListener(Base_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + } } onWindowScroll() { const prefs = this.props.Prefs.values; @@ -9396,6 +9643,53 @@ class BaseContent extends (external_React_default()).PureComponent { setPref(pref, value) { this.props.dispatch(actionCreators.SetPref(pref, value)); } + renderWallpaperAttribution() { + const { + wallpaperList + } = this.props.Wallpapers; + const activeWallpaper = this.props.Prefs.values[`newtabWallpapers.wallpaper-${this.state.colorMode}`]; + const selected = wallpaperList.find(wp => wp.title === activeWallpaper); + // make sure a wallpaper is selected and that the attribution also exists + if (!selected?.attribution) { + return null; + } + const { + name, + webpage + } = selected.attribution; + if (activeWallpaper && wallpaperList && name.url) { + return /*#__PURE__*/external_React_default().createElement("p", { + className: `wallpaper-attribution`, + key: name, + "data-l10n-id": "newtab-wallpaper-attribution", + "data-l10n-args": JSON.stringify({ + author_string: name.string, + author_url: name.url, + webpage_string: webpage.string, + webpage_url: webpage.url + }) + }, /*#__PURE__*/external_React_default().createElement("a", { + "data-l10n-name": "name-link", + href: name.url + }, name.string), /*#__PURE__*/external_React_default().createElement("a", { + "data-l10n-name": "webpage-link", + href: webpage.url + }, webpage.string)); + } + return null; + } + async updateWallpaper() { + const prefs = this.props.Prefs.values; + const { + wallpaperList + } = this.props.Wallpapers; + if (wallpaperList) { + const lightWallpaper = wallpaperList.find(wp => wp.title === prefs["newtabWallpapers.wallpaper-light"]) || ""; + const darkWallpaper = wallpaperList.find(wp => wp.title === prefs["newtabWallpapers.wallpaper-dark"]) || ""; + __webpack_require__.g.document?.body.style.setProperty(`--newtab-wallpaper-light`, `url(${lightWallpaper?.wallpaperUrl || ""})`); + __webpack_require__.g.document?.body.style.setProperty(`--newtab-wallpaper-dark`, `url(${darkWallpaper?.wallpaperUrl || ""})`); + } + } render() { const { props @@ -9408,6 +9702,8 @@ class BaseContent extends (external_React_default()).PureComponent { customizeMenuVisible } = App; const prefs = props.Prefs.values; + const activeWallpaper = prefs[`newtabWallpapers.wallpaper-${this.state.colorMode}`]; + const wallpapersEnabled = prefs["newtabWallpapers.enabled"]; const { pocketConfig } = prefs; @@ -9435,12 +9731,17 @@ class BaseContent extends (external_React_default()).PureComponent { mayHaveSponsoredTopSites } = prefs; const outerClassName = ["outer-wrapper", isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment", isDiscoveryStream && "ds-outer-wrapper-breakpoint-override", prefs.showSearch && this.state.fixedSearch && !noSectionsEnabled && "fixed-search", prefs.showSearch && noSectionsEnabled && "only-search", prefs["logowordmark.alwaysVisible"] && "visible-logo"].filter(v => v).join(" "); + if (wallpapersEnabled) { + this.updateWallpaper(); + } return /*#__PURE__*/external_React_default().createElement("div", null, /*#__PURE__*/external_React_default().createElement(CustomizeMenu, { onClose: this.closeCustomizationMenu, onOpen: this.openCustomizationMenu, openPreferences: this.openPreferences, setPref: this.setPref, enabledSections: enabledSections, + wallpapersEnabled: wallpapersEnabled, + activeWallpaper: activeWallpaper, pocketRegion: pocketRegion, mayHaveSponsoredTopSites: mayHaveSponsoredTopSites, mayHaveSponsoredStories: mayHaveSponsoredStories, @@ -9460,31 +9761,38 @@ class BaseContent extends (external_React_default()).PureComponent { className: "borderless-error" }, /*#__PURE__*/external_React_default().createElement(DiscoveryStreamBase, { locale: props.App.locale, - mayHaveSponsoredStories: mayHaveSponsoredStories - })) : /*#__PURE__*/external_React_default().createElement(Sections_Sections, null)), /*#__PURE__*/external_React_default().createElement(ConfirmDialog, null)))); + mayHaveSponsoredStories: mayHaveSponsoredStories, + firstVisibleTimestamp: this.state.firstVisibleTimestamp + })) : /*#__PURE__*/external_React_default().createElement(Sections_Sections, null)), /*#__PURE__*/external_React_default().createElement(ConfirmDialog, null), wallpapersEnabled && this.renderWallpaperAttribution()))); } } +BaseContent.defaultProps = { + document: __webpack_require__.g.document +}; const Base = (0,external_ReactRedux_namespaceObject.connect)(state => ({ App: state.App, Prefs: state.Prefs, Sections: state.Sections, DiscoveryStream: state.DiscoveryStream, - Search: state.Search + Search: state.Search, + Wallpapers: state.Wallpapers }))(_Base); -;// CONCATENATED MODULE: ./content-src/lib/detect-user-session-start.js +;// CONCATENATED MODULE: ./content-src/lib/detect-user-session-start.mjs /* 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/. */ + const detect_user_session_start_VISIBLE = "visible"; const detect_user_session_start_VISIBILITY_CHANGE_EVENT = "visibilitychange"; + class DetectUserSessionStart { constructor(store, options = {}) { this._store = store; // Overrides for testing - this.document = options.document || __webpack_require__.g.document; + this.document = options.document || globalThis.document; this._perfService = options.perfService || perfService; this._onVisibilityChange = this._onVisibilityChange.bind(this); } @@ -9502,7 +9810,10 @@ class DetectUserSessionStart { this._sendEvent(); } else { // If the document is not visible, listen for when it does become visible. - this.document.addEventListener(detect_user_session_start_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + this.document.addEventListener( + detect_user_session_start_VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); } } @@ -9513,14 +9824,19 @@ class DetectUserSessionStart { */ _sendEvent() { this._perfService.mark("visibility_event_rcvd_ts"); + try { - let visibility_event_rcvd_ts = this._perfService.getMostRecentAbsMarkStartByName("visibility_event_rcvd_ts"); - this._store.dispatch(actionCreators.AlsoToMain({ - type: actionTypes.SAVE_SESSION_PERF_DATA, - data: { - visibility_event_rcvd_ts - } - })); + let visibility_event_rcvd_ts = + this._perfService.getMostRecentAbsMarkStartByName( + "visibility_event_rcvd_ts" + ); + + this._store.dispatch( + actionCreators.AlsoToMain({ + type: actionTypes.SAVE_SESSION_PERF_DATA, + data: { visibility_event_rcvd_ts }, + }) + ); } catch (ex) { // If this failed, it's likely because the `privacy.resistFingerprinting` // pref is true. We should at least not blow up. @@ -9534,13 +9850,17 @@ class DetectUserSessionStart { _onVisibilityChange() { if (this.document.visibilityState === detect_user_session_start_VISIBLE) { this._sendEvent(); - this.document.removeEventListener(detect_user_session_start_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + this.document.removeEventListener( + detect_user_session_start_VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); } } } + ;// CONCATENATED MODULE: external "Redux" const external_Redux_namespaceObject = Redux; -;// CONCATENATED MODULE: ./content-src/lib/init-store.js +;// CONCATENATED MODULE: ./content-src/lib/init-store.mjs /* 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/. */ @@ -9548,6 +9868,10 @@ const external_Redux_namespaceObject = Redux; /* eslint-env mozilla/remote-page */ +// We disable import checking here as redux is installed via the npm packages +// at the newtab level, rather than in the top-level package.json. +// eslint-disable-next-line import/no-unresolved + const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain"; @@ -9572,11 +9896,9 @@ const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent"; function mergeStateReducer(mainReducer) { return (prevState, action) => { if (action.type === MERGE_STORE_ACTION) { - return { - ...prevState, - ...action.data - }; + return { ...prevState, ...action.data }; } + return mainReducer(prevState, action); }; } @@ -9593,9 +9915,8 @@ const messageMiddleware = () => next => action => { next(action); } }; -const rehydrationMiddleware = ({ - getState -}) => { + +const rehydrationMiddleware = ({ getState }) => { // NB: The parameter here is MiddlewareAPI which looks like a Store and shares // the same getState, so attached properties are accessible from the store. getState.didRehydrate = false; @@ -9604,17 +9925,24 @@ const rehydrationMiddleware = ({ if (getState.didRehydrate || window.__FROM_STARTUP_CACHE__) { // Startup messages can be safely ignored by the about:home document // stored in the startup cache. - if (window.__FROM_STARTUP_CACHE__ && action.meta && action.meta.isStartup) { + if ( + window.__FROM_STARTUP_CACHE__ && + action.meta && + action.meta.isStartup + ) { return null; } return next(action); } + const isMergeStoreAction = action.type === MERGE_STORE_ACTION; const isRehydrationRequest = action.type === actionTypes.NEW_TAB_STATE_REQUEST; + if (isRehydrationRequest) { getState.didRequestInitialState = true; return next(action); } + if (isMergeStoreAction) { getState.didRehydrate = true; return next(action); @@ -9622,16 +9950,20 @@ const rehydrationMiddleware = ({ // If init happened after our request was made, we need to re-request if (getState.didRequestInitialState && action.type === actionTypes.INIT) { - return next(actionCreators.AlsoToMain({ - type: actionTypes.NEW_TAB_STATE_REQUEST - })); + return next(actionCreators.AlsoToMain({ type: actionTypes.NEW_TAB_STATE_REQUEST })); } - if (actionUtils.isBroadcastToContent(action) || actionUtils.isSendToOneContent(action) || actionUtils.isSendToPreloaded(action)) { + + if ( + actionUtils.isBroadcastToContent(action) || + actionUtils.isSendToOneContent(action) || + actionUtils.isSendToPreloaded(action) + ) { // Note that actions received before didRehydrate will not be dispatched // because this could negatively affect preloading and the the state // will be replaced by rehydration anyway. return null; } + return next(action); }; }; @@ -9644,19 +9976,31 @@ const rehydrationMiddleware = ({ * @return {object} A redux store */ function initStore(reducers, initialState) { - const store = (0,external_Redux_namespaceObject.createStore)(mergeStateReducer((0,external_Redux_namespaceObject.combineReducers)(reducers)), initialState, __webpack_require__.g.RPMAddMessageListener && (0,external_Redux_namespaceObject.applyMiddleware)(rehydrationMiddleware, messageMiddleware)); - if (__webpack_require__.g.RPMAddMessageListener) { - __webpack_require__.g.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { + const store = (0,external_Redux_namespaceObject.createStore)( + mergeStateReducer((0,external_Redux_namespaceObject.combineReducers)(reducers)), + initialState, + globalThis.RPMAddMessageListener && + (0,external_Redux_namespaceObject.applyMiddleware)(rehydrationMiddleware, messageMiddleware) + ); + + if (globalThis.RPMAddMessageListener) { + globalThis.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { try { store.dispatch(msg.data); } catch (ex) { console.error("Content msg:", msg, "Dispatch error: ", ex); - dump(`Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ex.stack}`); + dump( + `Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ + ex.stack + }` + ); } }); } + return store; } + ;// CONCATENATED MODULE: external "ReactDOM" const external_ReactDOM_namespaceObject = ReactDOM; var external_ReactDOM_default = /*#__PURE__*/__webpack_require__.n(external_ReactDOM_namespaceObject); diff --git a/browser/components/newtab/data/content/assets/wallpapers/dark-beach.avif b/browser/components/newtab/data/content/assets/wallpapers/dark-beach.avif Binary files differnew file mode 100644 index 0000000000..5b77286079 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/dark-beach.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/dark-color.avif b/browser/components/newtab/data/content/assets/wallpapers/dark-color.avif Binary files differnew file mode 100644 index 0000000000..a4fc8e2341 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/dark-color.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/dark-landscape.avif b/browser/components/newtab/data/content/assets/wallpapers/dark-landscape.avif Binary files differnew file mode 100644 index 0000000000..ed22325f00 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/dark-landscape.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/dark-mountain.avif b/browser/components/newtab/data/content/assets/wallpapers/dark-mountain.avif Binary files differnew file mode 100644 index 0000000000..a704809a12 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/dark-mountain.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/dark-panda.avif b/browser/components/newtab/data/content/assets/wallpapers/dark-panda.avif Binary files differnew file mode 100644 index 0000000000..decfff669b --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/dark-panda.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/dark-sky.avif b/browser/components/newtab/data/content/assets/wallpapers/dark-sky.avif Binary files differnew file mode 100644 index 0000000000..51eea392ca --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/dark-sky.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/light-beach.avif b/browser/components/newtab/data/content/assets/wallpapers/light-beach.avif Binary files differnew file mode 100644 index 0000000000..b5f7b2ae67 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/light-beach.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/light-color.avif b/browser/components/newtab/data/content/assets/wallpapers/light-color.avif Binary files differnew file mode 100644 index 0000000000..3366b7aec6 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/light-color.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/light-landscape.avif b/browser/components/newtab/data/content/assets/wallpapers/light-landscape.avif Binary files differnew file mode 100644 index 0000000000..1776091825 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/light-landscape.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/light-mountain.avif b/browser/components/newtab/data/content/assets/wallpapers/light-mountain.avif Binary files differnew file mode 100644 index 0000000000..5983c942fc --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/light-mountain.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/light-panda.avif b/browser/components/newtab/data/content/assets/wallpapers/light-panda.avif Binary files differnew file mode 100644 index 0000000000..d20f405e45 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/light-panda.avif diff --git a/browser/components/newtab/data/content/assets/wallpapers/light-sky.avif b/browser/components/newtab/data/content/assets/wallpapers/light-sky.avif Binary files differnew file mode 100644 index 0000000000..f152f00e06 --- /dev/null +++ b/browser/components/newtab/data/content/assets/wallpapers/light-sky.avif diff --git a/browser/components/newtab/karma.mc.config.js b/browser/components/newtab/karma.mc.config.js index fa3ac14587..886b19df7b 100644 --- a/browser/components/newtab/karma.mc.config.js +++ b/browser/components/newtab/karma.mc.config.js @@ -158,6 +158,15 @@ module.exports = function (config) { functions: 0, branches: 0, }, + /** + * WallpaperFeed.sys.mjs is tested via an xpcshell test + */ + "lib/WallpaperFeed.sys.mjs": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, "content-src/components/DiscoveryStreamComponents/**/*.jsx": { statements: 90.48, lines: 90.48, @@ -170,6 +179,15 @@ module.exports = function (config) { functions: 60, branches: 50, }, + /** + * WallpaperSection.jsx is tested via an xpcshell test + */ + "content-src/components/WallpapersSection/*.jsx": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, "content-src/components/DiscoveryStreamAdmin/*.jsx": { statements: 0, lines: 0, @@ -211,7 +229,7 @@ module.exports = function (config) { devtool: "inline-source-map", // This resolve config allows us to import with paths relative to the root directory, e.g. "lib/ActivityStream.sys.mjs" resolve: { - extensions: [".js", ".jsx"], + extensions: [".js", ".jsx", ".mjs"], modules: [PATHS.moduleResolveDirectory, "node_modules"], alias: { asrouter: path.join(__dirname, "../asrouter"), @@ -260,7 +278,7 @@ module.exports = function (config) { }, { enforce: "post", - test: /\.js[mx]?$/, + test: /\.js[x]?$/, loader: "@jsdevtools/coverage-istanbul-loader", options: { esModules: true }, include: [ diff --git a/browser/components/newtab/lib/AboutPreferences.sys.mjs b/browser/components/newtab/lib/AboutPreferences.sys.mjs index 33f7ecdaeb..08e0ca422a 100644 --- a/browser/components/newtab/lib/AboutPreferences.sys.mjs +++ b/browser/components/newtab/lib/AboutPreferences.sys.mjs @@ -5,7 +5,7 @@ import { actionTypes as at, actionCreators as ac, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; const HTML_NS = "http://www.w3.org/1999/xhtml"; export const PREFERENCES_LOADED_EVENT = "home-pane-loaded"; diff --git a/browser/components/newtab/lib/ActivityStream.sys.mjs b/browser/components/newtab/lib/ActivityStream.sys.mjs index f46e8aadf0..fa2d011f11 100644 --- a/browser/components/newtab/lib/ActivityStream.sys.mjs +++ b/browser/components/newtab/lib/ActivityStream.sys.mjs @@ -36,6 +36,7 @@ ChromeUtils.defineESModuleGetters(lazy, { TelemetryFeed: "resource://activity-stream/lib/TelemetryFeed.sys.mjs", TopSitesFeed: "resource://activity-stream/lib/TopSitesFeed.sys.mjs", TopStoriesFeed: "resource://activity-stream/lib/TopStoriesFeed.sys.mjs", + WallpaperFeed: "resource://activity-stream/lib/WallpaperFeed.sys.mjs", }); // NB: Eagerly load modules that will be loaded/constructed/initialized in the @@ -43,7 +44,7 @@ ChromeUtils.defineESModuleGetters(lazy, { import { actionCreators as ac, actionTypes as at, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; const REGION_BASIC_CONFIG = "browser.newtabpage.activity-stream.discoverystream.region-basic-config"; @@ -233,6 +234,27 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "newtabWallpapers.enabled", + { + title: "Boolean flag to turn wallpaper functionality on and off", + value: true, + }, + ], + [ + "newtabWallpapers.wallpaper-light", + { + title: "Currently set light wallpaper", + value: "", + }, + ], + [ + "newtabWallpapers.wallpaper-dark", + { + title: "Currently set dark wallpaper", + value: "", + }, + ], + [ "improvesearch.noDefaultSearchTile", { title: "Remove tiles that are the same as the default search", @@ -524,6 +546,12 @@ const FEEDS_DATA = [ title: "Handles new pocket ui for the new tab page", value: true, }, + { + name: "wallpaperfeed", + factory: () => new lazy.WallpaperFeed(), + title: "Handles fetching and managing wallpaper data from RemoteSettings", + value: true, + }, ]; const FEEDS_CONFIG = new Map(); diff --git a/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs b/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs index 5392a421ca..3cb81b4793 100644 --- a/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs +++ b/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs @@ -13,7 +13,7 @@ import { actionCreators as ac, actionTypes as at, actionUtils as au, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; const ABOUT_NEW_TAB_URL = "about:newtab"; diff --git a/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs b/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs index ee08462503..bff9f1e04e 100644 --- a/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs +++ b/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs @@ -26,7 +26,7 @@ const { setTimeout, clearTimeout } = ChromeUtils.importESModule( import { actionTypes as at, actionCreators as ac, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; const CACHE_KEY = "discovery_stream"; const STARTUP_CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week @@ -565,8 +565,8 @@ export class DiscoveryStreamFeed { generateFeedUrl(isBff) { if (isBff) { - return `https://${lazy.NimbusFeatures.saveToPocket.getVariable( - "bffApi" + return `https://${Services.prefs.getStringPref( + "extensions.pocket.bffApi" )}/desktop/v1/recommendations?locale=$locale®ion=$region&count=30`; } return FEED_URL; @@ -986,8 +986,9 @@ export class DiscoveryStreamFeed { }); if (spocsResponse) { + const fetchTimestamp = Date.now(); spocsState = { - lastUpdated: Date.now(), + lastUpdated: fetchTimestamp, spocs: { ...spocsResponse, }, @@ -1050,8 +1051,13 @@ export class DiscoveryStreamFeed { const { data: blockedResults } = this.filterBlocked(capResult); + const { data: spocsWithFetchTimestamp } = this.addFetchTimestamp( + blockedResults, + fetchTimestamp + ); + const { data: scoredResults, personalized } = - await this.scoreItems(blockedResults, "spocs"); + await this.scoreItems(spocsWithFetchTimestamp, "spocs"); spocsState.spocs = { ...spocsState.spocs, @@ -1209,6 +1215,22 @@ export class DiscoveryStreamFeed { return { data }; } + // Add the fetch timestamp property to each spoc returned to communicate how + // old the spoc is in telemetry when it is used by the client + addFetchTimestamp(spocs, fetchTimestamp) { + if (spocs && spocs.length) { + return { + data: spocs.map(s => { + return { + ...s, + fetchTimestamp, + }; + }), + }; + } + return { data: spocs }; + } + // For backwards compatibility, older spoc endpoint don't have flight_id, // but instead had campaign_id we can use // @@ -1334,8 +1356,8 @@ export class DiscoveryStreamFeed { let options = {}; if (this.isBff) { const headers = new Headers(); - const oAuthConsumerKey = lazy.NimbusFeatures.saveToPocket.getVariable( - "oAuthConsumerKeyBff" + const oAuthConsumerKey = Services.prefs.getStringPref( + "extensions.pocket.oAuthConsumerKeyBff" ); headers.append("consumer_key", oAuthConsumerKey); options = { @@ -1768,7 +1790,7 @@ export class DiscoveryStreamFeed { break; // Check if spocs was disabled. Remove them if they were. case PREF_SHOW_SPONSORED: - case PREF_SHOW_SPONSORED_TOPSITES: + case PREF_SHOW_SPONSORED_TOPSITES: { const dispatch = update => this.store.dispatch(ac.BroadcastToContent(update)); // We refresh placements data because one of the spocs were turned off. @@ -1794,6 +1816,7 @@ export class DiscoveryStreamFeed { await this.cache.set("spocs", {}); await this.loadSpocs(dispatch); break; + } } } diff --git a/browser/components/newtab/lib/DownloadsManager.sys.mjs b/browser/components/newtab/lib/DownloadsManager.sys.mjs index a9a57222ee..f6e99e462a 100644 --- a/browser/components/newtab/lib/DownloadsManager.sys.mjs +++ b/browser/components/newtab/lib/DownloadsManager.sys.mjs @@ -2,7 +2,7 @@ * 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/. */ -import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs"; const lazy = {}; diff --git a/browser/components/newtab/lib/FaviconFeed.sys.mjs b/browser/components/newtab/lib/FaviconFeed.sys.mjs index a76566d3e8..18c2231f58 100644 --- a/browser/components/newtab/lib/FaviconFeed.sys.mjs +++ b/browser/components/newtab/lib/FaviconFeed.sys.mjs @@ -2,7 +2,7 @@ * 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/. */ -import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs"; import { getDomain } from "resource://activity-stream/lib/TippyTopProvider.sys.mjs"; // We use importESModule here instead of static import so that diff --git a/browser/components/newtab/lib/HighlightsFeed.sys.mjs b/browser/components/newtab/lib/HighlightsFeed.sys.mjs index c603b886da..00eb109896 100644 --- a/browser/components/newtab/lib/HighlightsFeed.sys.mjs +++ b/browser/components/newtab/lib/HighlightsFeed.sys.mjs @@ -2,7 +2,7 @@ * 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/. */ -import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs"; import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; import { diff --git a/browser/components/newtab/lib/NewTabInit.sys.mjs b/browser/components/newtab/lib/NewTabInit.sys.mjs index db30e009ec..768cc29ea4 100644 --- a/browser/components/newtab/lib/NewTabInit.sys.mjs +++ b/browser/components/newtab/lib/NewTabInit.sys.mjs @@ -5,7 +5,7 @@ import { actionCreators as ac, actionTypes as at, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; /** * NewTabInit - A placeholder for now. This will send a copy of the state to all diff --git a/browser/components/newtab/lib/PlacesFeed.sys.mjs b/browser/components/newtab/lib/PlacesFeed.sys.mjs index 70011412f8..85679153bd 100644 --- a/browser/components/newtab/lib/PlacesFeed.sys.mjs +++ b/browser/components/newtab/lib/PlacesFeed.sys.mjs @@ -6,7 +6,7 @@ import { actionCreators as ac, actionTypes as at, actionUtils as au, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; diff --git a/browser/components/newtab/lib/PrefsFeed.sys.mjs b/browser/components/newtab/lib/PrefsFeed.sys.mjs index bb2502ac55..4cb41c0421 100644 --- a/browser/components/newtab/lib/PrefsFeed.sys.mjs +++ b/browser/components/newtab/lib/PrefsFeed.sys.mjs @@ -5,7 +5,7 @@ import { actionCreators as ac, actionTypes as at, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; // We use importESModule here instead of static import so that diff --git a/browser/components/newtab/lib/RecommendationProvider.sys.mjs b/browser/components/newtab/lib/RecommendationProvider.sys.mjs index 875c90492b..9fd6b71656 100644 --- a/browser/components/newtab/lib/RecommendationProvider.sys.mjs +++ b/browser/components/newtab/lib/RecommendationProvider.sys.mjs @@ -12,7 +12,7 @@ ChromeUtils.defineESModuleGetters(lazy, { import { actionTypes as at, actionCreators as ac, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; const CACHE_KEY = "personalization"; const PREF_PERSONALIZATION_MODEL_KEYS = diff --git a/browser/components/newtab/lib/SectionsManager.sys.mjs b/browser/components/newtab/lib/SectionsManager.sys.mjs index 069ddbb224..a1634e0d47 100644 --- a/browser/components/newtab/lib/SectionsManager.sys.mjs +++ b/browser/components/newtab/lib/SectionsManager.sys.mjs @@ -15,7 +15,7 @@ const { EventEmitter } = ChromeUtils.importESModule( import { actionCreators as ac, actionTypes as at, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; import { getDefaultOptions } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; const lazy = {}; @@ -389,7 +389,7 @@ export const SectionsManager = { /** * Sets each card in highlights' context menu options based on the card's type. - * (See types.js for a list of types) + * (See types.mjs for a list of types) * * @param rows section rows containing a type for each card */ diff --git a/browser/components/newtab/lib/SystemTickFeed.sys.mjs b/browser/components/newtab/lib/SystemTickFeed.sys.mjs index d87860fab2..fdbbda3ddd 100644 --- a/browser/components/newtab/lib/SystemTickFeed.sys.mjs +++ b/browser/components/newtab/lib/SystemTickFeed.sys.mjs @@ -2,7 +2,7 @@ * 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/. */ -import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs"; const lazy = {}; diff --git a/browser/components/newtab/lib/TelemetryFeed.sys.mjs b/browser/components/newtab/lib/TelemetryFeed.sys.mjs index 1a9e9e3d34..6cf4dba4ab 100644 --- a/browser/components/newtab/lib/TelemetryFeed.sys.mjs +++ b/browser/components/newtab/lib/TelemetryFeed.sys.mjs @@ -18,13 +18,13 @@ const { XPCOMUtils } = ChromeUtils.importESModule( // eslint-disable-next-line mozilla/use-static-import const { MESSAGE_TYPE_HASH: msg } = ChromeUtils.importESModule( - "resource:///modules/asrouter/ActorConstants.sys.mjs" + "resource:///modules/asrouter/ActorConstants.mjs" ); import { actionTypes as at, actionUtils as au, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; import { classifySite } from "resource://activity-stream/lib/SiteClassifier.sys.mjs"; @@ -454,8 +454,7 @@ export class TelemetryFeed { event = await this.applyCFRPolicy(event); break; case "badge_user_event": - case "whats-new-panel_user_event": - event = await this.applyWhatsNewPolicy(event); + event = await this.applyToolbarBadgePolicy(event); break; case "infobar_user_event": event = await this.applyInfoBarPolicy(event); @@ -509,12 +508,12 @@ export class TelemetryFeed { * Per Bug 1482134, all the metrics for What's New panel use client_id in * all the release channels */ - async applyWhatsNewPolicy(ping) { + async applyToolbarBadgePolicy(ping) { ping.client_id = await this.telemetryClientId; ping.browser_session_id = lazy.browserSessionId; // Attach page info to `event_context` if there is a session associated with this ping delete ping.action; - return { ping, pingType: "whats-new-panel" }; + return { ping, pingType: "toolbar-badge" }; } async applyInfoBarPolicy(ping) { @@ -715,8 +714,16 @@ export class TelemetryFeed { const session = this.sessions.get(au.getPortIdOfSender(action)); switch (action.data?.event) { case "CLICK": { - const { card_type, topic, recommendation_id, tile_id, shim, feature } = - action.data.value ?? {}; + const { + card_type, + topic, + recommendation_id, + tile_id, + shim, + fetchTimestamp, + firstVisibleTimestamp, + feature, + } = action.data.value ?? {}; if ( action.data.source === "POPULAR_TOPICS" || card_type === "topics_widget" @@ -740,6 +747,14 @@ export class TelemetryFeed { }); if (shim) { Glean.pocket.shim.set(shim); + if (fetchTimestamp) { + Glean.pocket.fetchTimestamp.set(fetchTimestamp * 1000); + } + if (firstVisibleTimestamp) { + Glean.pocket.newtabCreationTimestamp.set( + firstVisibleTimestamp * 1000 + ); + } GleanPings.spoc.submit("click"); } } @@ -755,6 +770,16 @@ export class TelemetryFeed { }); if (action.data.value?.shim) { Glean.pocket.shim.set(action.data.value.shim); + if (action.data.value.fetchTimestamp) { + Glean.pocket.fetchTimestamp.set( + action.data.value.fetchTimestamp * 1000 + ); + } + if (action.data.value.newtabCreationTimestamp) { + Glean.pocket.newtabCreationTimestamp.set( + action.data.value.newtabCreationTimestamp * 1000 + ); + } GleanPings.spoc.submit("save"); } break; @@ -976,6 +1001,14 @@ export class TelemetryFeed { }); if (tile.shim) { Glean.pocket.shim.set(tile.shim); + if (tile.fetchTimestamp) { + Glean.pocket.fetchTimestamp.set(tile.fetchTimestamp * 1000); + } + if (data.firstVisibleTimestamp) { + Glean.pocket.newtabCreationTimestamp.set( + data.firstVisibleTimestamp * 1000 + ); + } GleanPings.spoc.submit("impression"); } }); diff --git a/browser/components/newtab/lib/TopSitesFeed.sys.mjs b/browser/components/newtab/lib/TopSitesFeed.sys.mjs index 796211085b..e259253402 100644 --- a/browser/components/newtab/lib/TopSitesFeed.sys.mjs +++ b/browser/components/newtab/lib/TopSitesFeed.sys.mjs @@ -5,7 +5,7 @@ import { actionCreators as ac, actionTypes as at, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; import { TippyTopProvider } from "resource://activity-stream/lib/TippyTopProvider.sys.mjs"; import { insertPinned, diff --git a/browser/components/newtab/lib/TopStoriesFeed.sys.mjs b/browser/components/newtab/lib/TopStoriesFeed.sys.mjs index be030649dd..5986209a1c 100644 --- a/browser/components/newtab/lib/TopStoriesFeed.sys.mjs +++ b/browser/components/newtab/lib/TopStoriesFeed.sys.mjs @@ -5,7 +5,7 @@ import { actionTypes as at, actionCreators as ac, -} from "resource://activity-stream/common/Actions.sys.mjs"; +} from "resource://activity-stream/common/Actions.mjs"; import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; import { SectionsManager } from "resource://activity-stream/lib/SectionsManager.sys.mjs"; diff --git a/browser/components/newtab/lib/WallpaperFeed.sys.mjs b/browser/components/newtab/lib/WallpaperFeed.sys.mjs new file mode 100644 index 0000000000..cb21311ddc --- /dev/null +++ b/browser/components/newtab/lib/WallpaperFeed.sys.mjs @@ -0,0 +1,117 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", +}); + +import { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.mjs"; + +const PREF_WALLPAPERS_ENABLED = + "browser.newtabpage.activity-stream.newtabWallpapers.enabled"; + +export class WallpaperFeed { + constructor() { + this.loaded = false; + this.wallpaperClient = ""; + this.wallpaperDB = ""; + this.baseAttachmentURL = ""; + } + + /** + * This thin wrapper around global.fetch makes it easier for us to write + * automated tests that simulate responses from this fetch. + */ + fetch(...args) { + return fetch(...args); + } + + /** + * This thin wrapper around lazy.RemoteSettings makes it easier for us to write + * automated tests that simulate responses from this fetch. + */ + RemoteSettings(...args) { + return lazy.RemoteSettings(...args); + } + + async wallpaperSetup(isStartup = false) { + const wallpapersEnabled = Services.prefs.getBoolPref( + PREF_WALLPAPERS_ENABLED + ); + + if (wallpapersEnabled) { + if (!this.wallpaperClient) { + this.wallpaperClient = this.RemoteSettings("newtab-wallpapers"); + } + + await this.getBaseAttachment(); + this.wallpaperClient.on("sync", () => this.updateWallpapers()); + this.updateWallpapers(isStartup); + } + } + + async getBaseAttachment() { + if (!this.baseAttachmentURL) { + const SERVER = lazy.Utils.SERVER_URL; + const serverInfo = await ( + await this.fetch(`${SERVER}/`, { + credentials: "omit", + }) + ).json(); + const { base_url } = serverInfo.capabilities.attachments; + this.baseAttachmentURL = base_url; + } + } + + async updateWallpapers(isStartup = false) { + const records = await this.wallpaperClient.get(); + if (!records?.length) { + return; + } + + if (!this.baseAttachmentURL) { + await this.getBaseAttachment(); + } + const wallpapers = records.map(record => { + return { + ...record, + wallpaperUrl: `${this.baseAttachmentURL}${record.attachment.location}`, + }; + }); + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.WALLPAPERS_SET, + data: wallpapers, + meta: { + isStartup, + }, + }) + ); + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + await this.wallpaperSetup(true /* isStartup */); + break; + case at.UNINIT: + break; + case at.SYSTEM_TICK: + break; + case at.PREF_CHANGED: + if (action.data.name === "newtabWallpapers.enabled") { + await this.wallpaperSetup(false /* isStartup */); + } + break; + case at.WALLPAPERS_SET: + break; + } + } +} diff --git a/browser/components/newtab/metrics.yaml b/browser/components/newtab/metrics.yaml index bd74e609ad..c59247ceef 100644 --- a/browser/components/newtab/metrics.yaml +++ b/browser/components/newtab/metrics.yaml @@ -817,6 +817,35 @@ pocket: send_in_pings: - spoc + fetch_timestamp: + type: datetime + lifetime: ping + description: | + Timestamp of when the spoc was fetched by the client + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887655 + notification_emails: + - dmueller@mozilla.com + expires: never + send_in_pings: + - spoc + + newtab_creation_timestamp: + type: datetime + lifetime: ping + description: | + Timestamp of when this instance of the newtab was first visible to the user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887655 + notification_emails: + - dmueller@mozilla.com + expires: never + send_in_pings: + - spoc messaging_system: event_context_parse_error: @@ -1031,7 +1060,7 @@ messaging_system: type: string description: > Type of event the ping is capturing. - e.g. "cfr", "whats-new-panel", "onboarding" + e.g. "cfr", "onboarding" bugs: - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 data_reviews: diff --git a/browser/components/newtab/test/browser/browser_as_load_location.js b/browser/components/newtab/test/browser/browser_as_load_location.js index f11b6cf503..ce67ede0c6 100644 --- a/browser/components/newtab/test/browser/browser_as_load_location.js +++ b/browser/components/newtab/test/browser/browser_as_load_location.js @@ -8,7 +8,7 @@ */ async function checkNewtabLoads(selector, message) { // simulate a newtab open as a user would - BrowserOpenTab(); + BrowserCommands.openTab(); // wait until the browser loads let browser = gBrowser.selectedBrowser; diff --git a/browser/components/newtab/test/browser/browser_newtab_overrides.js b/browser/components/newtab/test/browser/browser_newtab_overrides.js index 1d4a0c36e3..c876a62c4e 100644 --- a/browser/components/newtab/test/browser/browser_newtab_overrides.js +++ b/browser/components/newtab/test/browser/browser_newtab_overrides.js @@ -82,7 +82,7 @@ add_task(async function override_loads_in_browser() { Assert.ok(AboutNewTab.newTabURLOverridden, "url has been overridden"); // simulate a newtab open as a user would - BrowserOpenTab(); + BrowserCommands.openTab(); let browser = gBrowser.selectedBrowser; await BrowserTestUtils.browserLoaded(browser); @@ -116,7 +116,7 @@ add_task(async function override_blank_loads_in_browser() { Assert.ok(AboutNewTab.newTabURLOverridden, "url has been overridden"); // simulate a newtab open as a user would - BrowserOpenTab(); + BrowserCommands.openTab(); let browser = gBrowser.selectedBrowser; await BrowserTestUtils.browserLoaded(browser); diff --git a/browser/components/newtab/test/schemas/pings.js b/browser/components/newtab/test/schemas/pings.js index fb52602bd4..2a1dd35ec6 100644 --- a/browser/components/newtab/test/schemas/pings.js +++ b/browser/components/newtab/test/schemas/pings.js @@ -1,7 +1,4 @@ -import { - CONTENT_MESSAGE_TYPE, - MAIN_MESSAGE_TYPE, -} from "common/Actions.sys.mjs"; +import { CONTENT_MESSAGE_TYPE, MAIN_MESSAGE_TYPE } from "common/Actions.mjs"; import Joi from "joi-browser"; export const baseKeys = { diff --git a/browser/components/newtab/test/unit/common/Actions.test.js b/browser/components/newtab/test/unit/common/Actions.test.js index 32e417ea3f..af8d18cee8 100644 --- a/browser/components/newtab/test/unit/common/Actions.test.js +++ b/browser/components/newtab/test/unit/common/Actions.test.js @@ -8,7 +8,7 @@ import { MAIN_MESSAGE_TYPE, PRELOAD_MESSAGE_TYPE, UI_CODE, -} from "common/Actions.sys.mjs"; +} from "common/Actions.mjs"; describe("Actions", () => { it("should set globalImportContext to UI_CODE", () => { diff --git a/browser/components/newtab/test/unit/common/Reducers.test.js b/browser/components/newtab/test/unit/common/Reducers.test.js index 7343fc6224..62f6f48353 100644 --- a/browser/components/newtab/test/unit/common/Reducers.test.js +++ b/browser/components/newtab/test/unit/common/Reducers.test.js @@ -11,7 +11,7 @@ const { Search, ASRouter, } = reducers; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; describe("Reducers", () => { describe("App", () => { diff --git a/browser/components/newtab/test/unit/content-src/components/Base.test.jsx b/browser/components/newtab/test/unit/content-src/components/Base.test.jsx index c764348006..d8d300a3c9 100644 --- a/browser/components/newtab/test/unit/content-src/components/Base.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/Base.test.jsx @@ -8,7 +8,7 @@ import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundar import React from "react"; import { Search } from "content-src/components/Search/Search"; import { shallow } from "enzyme"; -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; describe("<Base>", () => { let DEFAULT_PROPS = { @@ -21,6 +21,11 @@ describe("<Base>", () => { adminContent: { message: {}, }, + document: { + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }, }; it("should render Base component", () => { @@ -76,6 +81,11 @@ describe("<BaseContent>", () => { Sections: [], DiscoveryStream: { config: { enabled: false } }, dispatch: () => {}, + document: { + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }, }; it("should render an ErrorBoundary with a Search child", () => { @@ -114,6 +124,73 @@ describe("<BaseContent>", () => { const wrapper = shallow(<BaseContent {...onlySearchProps} />); assert.lengthOf(wrapper.find(".only-search"), 1); }); + + it("should update firstVisibleTimestamp if it is visible immediately with no event listener", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + document: { + visibilityState: "visible", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + }); + + const wrapper = shallow(<BaseContent {...props} />); + assert.notCalled(props.document.addEventListener); + assert.isDefined(wrapper.state("firstVisibleTimestamp")); + }); + it("should attach an event listener for visibility change if it is not visible", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + }); + + const wrapper = shallow(<BaseContent {...props} />); + assert.calledWith(props.document.addEventListener, "visibilitychange"); + assert.notExists(wrapper.state("firstVisibleTimestamp")); + }); + it("should remove the event listener for visibility change when unmounted", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + }); + + const wrapper = shallow(<BaseContent {...props} />); + const [, listener] = props.document.addEventListener.firstCall.args; + + wrapper.unmount(); + assert.calledWith( + props.document.removeEventListener, + "visibilitychange", + listener + ); + }); + it("should remove the event listener for visibility change after becoming visible", () => { + const listeners = new Set(); + const props = Object.assign({}, DEFAULT_PROPS, { + document: { + visibilityState: "hidden", + addEventListener: (ev, cb) => listeners.add(cb), + removeEventListener: (ev, cb) => listeners.delete(cb), + }, + }); + + const wrapper = shallow(<BaseContent {...props} />); + assert.equal(listeners.size, 1); + assert.notExists(wrapper.state("firstVisibleTimestamp")); + + // Simulate listeners getting called + props.document.visibilityState = "visible"; + listeners.forEach(l => l()); + + assert.equal(listeners.size, 0); + assert.isDefined(wrapper.state("firstVisibleTimestamp")); + }); }); describe("<PrefsButton>", () => { diff --git a/browser/components/newtab/test/unit/content-src/components/Card.test.jsx b/browser/components/newtab/test/unit/content-src/components/Card.test.jsx index 5f07570b2e..f7f065efae 100644 --- a/browser/components/newtab/test/unit/content-src/components/Card.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/Card.test.jsx @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { _Card as Card, PlaceholderCard, diff --git a/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx b/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx index baf203947e..fcc1dd0f45 100644 --- a/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; import createMockRaf from "mock-raf"; import React from "react"; diff --git a/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx b/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx index a471c09e66..3befa4403f 100644 --- a/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { _ConfirmDialog as ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; import React from "react"; import { shallow } from "enzyme"; diff --git a/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx index e1f84f7d84..0407622cf9 100644 --- a/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx @@ -1,4 +1,4 @@ -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; import { mount } from "enzyme"; import React from "react"; diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamAdmin.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamAdmin.test.jsx index 41849fba3e..7f40b66200 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamAdmin.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamAdmin.test.jsx @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { DiscoveryStreamAdminInner, CollapseToggle, diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx index 418a731ba1..ffa32bfc3e 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx @@ -13,10 +13,7 @@ import { PlaceholderDSCard, } from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard"; import { TopicsWidget } from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget"; -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import React from "react"; import { shallow, mount } from "enzyme"; diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx index 1d572ee3ce..afb6d6dcd2 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx @@ -10,10 +10,7 @@ import { StatusMessage, SponsorLabel, } from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter"; -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu"; import React from "react"; import { INITIAL_STATE } from "common/Reducers.sys.mjs"; @@ -28,6 +25,8 @@ const DEFAULT_PROPS = { isForStartupCache: false, }, DiscoveryStream: INITIAL_STATE.DiscoveryStream, + fetchTimestamp: new Date("March 20, 2024 10:30:44").getTime(), + firstVisibleTimestamp: new Date("March 21, 2024 10:11:12").getTime(), }; describe("<DSCard>", () => { @@ -174,6 +173,8 @@ describe("<DSCard>", () => { card_type: "organic", recommendation_id: undefined, tile_id: "fooidx", + fetchTimestamp: DEFAULT_PROPS.fetchTimestamp, + firstVisibleTimestamp: DEFAULT_PROPS.firstVisibleTimestamp, }, }) ); @@ -212,6 +213,8 @@ describe("<DSCard>", () => { card_type: "spoc", recommendation_id: undefined, tile_id: "fooidx", + fetchTimestamp: DEFAULT_PROPS.fetchTimestamp, + firstVisibleTimestamp: DEFAULT_PROPS.firstVisibleTimestamp, }, }) ); @@ -258,6 +261,8 @@ describe("<DSCard>", () => { recommendation_id: undefined, tile_id: "fooidx", shim: "click shim", + fetchTimestamp: DEFAULT_PROPS.fetchTimestamp, + firstVisibleTimestamp: DEFAULT_PROPS.firstVisibleTimestamp, }, }) ); @@ -370,7 +375,12 @@ describe("<DSCard>", () => { describe("DSCard onSaveClick", () => { it("should fire telemetry for onSaveClick", () => { - wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" }); + wrapper.setProps({ + id: "fooidx", + pos: 1, + type: "foo", + fetchTimestamp: undefined, + }); wrapper.instance().onSaveClick(); assert.calledThrice(dispatch); @@ -391,6 +401,8 @@ describe("<DSCard>", () => { card_type: "organic", recommendation_id: undefined, tile_id: "fooidx", + fetchTimestamp: undefined, + firstVisibleTimestamp: DEFAULT_PROPS.firstVisibleTimestamp, }, }) ); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx index 08ac7868ce..a18e688758 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx @@ -5,7 +5,7 @@ import { } from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter"; import React from "react"; import { mount } from "enzyme"; -import { cardContextTypes } from "content-src/components/Card/types.js"; +import { cardContextTypes } from "content-src/components/Card/types.mjs"; import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText.jsx"; describe("<DSContextFooter>", () => { diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx index b4b743c7ff..b5acbf3b56 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx @@ -1,6 +1,6 @@ import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; import { shallow, mount } from "enzyme"; -import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { actionCreators as ac } from "common/Actions.mjs"; import React from "react"; describe("Discovery Stream <DSPrivacyModal>", () => { diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx index 4926cc6c70..c935acde1a 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx @@ -2,7 +2,7 @@ import { ImpressionStats, INTERSECTION_RATIO, } from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats"; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import React from "react"; import { shallow } from "enzyme"; @@ -33,12 +33,15 @@ describe("<ImpressionStats>", () => { }; } + const TEST_FETCH_TIMESTAMP = Date.now(); + const TEST_FIRST_VISIBLE_TIMESTAMP = Date.now(); const DEFAULT_PROPS = { rows: [ - { id: 1, pos: 0 }, - { id: 2, pos: 1 }, - { id: 3, pos: 2 }, + { id: 1, pos: 0, fetchTimestamp: TEST_FETCH_TIMESTAMP }, + { id: 2, pos: 1, fetchTimestamp: TEST_FETCH_TIMESTAMP }, + { id: 3, pos: 2, fetchTimestamp: TEST_FETCH_TIMESTAMP }, ], + firstVisibleTimestamp: TEST_FIRST_VISIBLE_TIMESTAMP, source: SOURCE, IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), document: { @@ -76,7 +79,7 @@ describe("<ImpressionStats>", () => { assert.notCalled(dispatch); }); - it("should noly send loaded content but not impression when the wrapped item is not visbible", () => { + it("should only send loaded content but not impression when the wrapped item is not visbible", () => { const dispatch = sinon.spy(); const props = { dispatch, @@ -128,11 +131,37 @@ describe("<ImpressionStats>", () => { [action] = dispatch.secondCall.args; assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS); assert.equal(action.data.source, SOURCE); + assert.equal( + action.data.firstVisibleTimestamp, + TEST_FIRST_VISIBLE_TIMESTAMP + ); assert.deepEqual(action.data.tiles, [ - { id: 1, pos: 0, type: "organic", recommendation_id: undefined }, - { id: 2, pos: 1, type: "organic", recommendation_id: undefined }, - { id: 3, pos: 2, type: "organic", recommendation_id: undefined }, + { + id: 1, + pos: 0, + type: "organic", + recommendation_id: undefined, + fetchTimestamp: TEST_FETCH_TIMESTAMP, + }, + { + id: 2, + pos: 1, + type: "organic", + recommendation_id: undefined, + fetchTimestamp: TEST_FETCH_TIMESTAMP, + }, + { + id: 3, + pos: 2, + type: "organic", + recommendation_id: undefined, + fetchTimestamp: TEST_FETCH_TIMESTAMP, + }, ]); + assert.equal( + action.data.firstVisibleTimestamp, + TEST_FIRST_VISIBLE_TIMESTAMP + ); }); it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a flightId", () => { const dispatch = sinon.spy(); @@ -207,10 +236,32 @@ describe("<ImpressionStats>", () => { [action] = dispatch.firstCall.args; assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS); assert.deepEqual(action.data.tiles, [ - { id: 1, pos: 0, type: "organic", recommendation_id: undefined }, - { id: 2, pos: 1, type: "organic", recommendation_id: undefined }, - { id: 3, pos: 2, type: "organic", recommendation_id: undefined }, + { + id: 1, + pos: 0, + type: "organic", + recommendation_id: undefined, + fetchTimestamp: TEST_FETCH_TIMESTAMP, + }, + { + id: 2, + pos: 1, + type: "organic", + recommendation_id: undefined, + fetchTimestamp: TEST_FETCH_TIMESTAMP, + }, + { + id: 3, + pos: 2, + type: "organic", + recommendation_id: undefined, + fetchTimestamp: TEST_FETCH_TIMESTAMP, + }, ]); + assert.equal( + action.data.firstVisibleTimestamp, + TEST_FIRST_VISIBLE_TIMESTAMP + ); }); it("should remove visibility change listener when the wrapper is removed", () => { const props = { diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx index f879600a8f..5c9dcb4c14 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx @@ -6,10 +6,7 @@ import { TopicsWidget, } from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget"; import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { mount } from "enzyme"; import React from "react"; diff --git a/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx b/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx index 9f4008369a..69d023c668 100644 --- a/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx @@ -5,7 +5,7 @@ import { SectionIntl, _Sections as Sections, } from "content-src/components/Sections/Sections"; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import { mount, shallow } from "enzyme"; import { PlaceholderCard } from "content-src/components/Card/Card"; import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx index 798bb9b8c7..9797a4863e 100644 --- a/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { GlobalOverrider } from "test/unit/utils"; import { MIN_RICH_FAVICON_SIZE } from "content-src/components/TopSites/TopSitesConstants"; import { diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx index 3f7e725de0..b1b501ca44 100644 --- a/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx @@ -2,7 +2,7 @@ import { TopSiteImpressionWrapper, INTERSECTION_RATIO, } from "content-src/components/TopSites/TopSiteImpressionWrapper"; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import React from "react"; import { shallow } from "enzyme"; diff --git a/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js b/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js index 5a7fad7cc0..3629bb7a68 100644 --- a/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js +++ b/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; describe("detectUserSessionStart", () => { diff --git a/browser/components/newtab/test/unit/content-src/lib/init-store.test.js b/browser/components/newtab/test/unit/content-src/lib/init-store.test.js index 0dd510ef1a..8f998b64d0 100644 --- a/browser/components/newtab/test/unit/content-src/lib/init-store.test.js +++ b/browser/components/newtab/test/unit/content-src/lib/init-store.test.js @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { addNumberReducer, GlobalOverrider } from "test/unit/utils"; import { INCOMING_MESSAGE_NAME, diff --git a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js index 233f31b6ca..fb28c9490b 100644 --- a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js +++ b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js @@ -1,5 +1,5 @@ import { combineReducers, createStore } from "redux"; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import { GlobalOverrider } from "test/unit/utils"; import { reducers } from "common/Reducers.sys.mjs"; import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; diff --git a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js index 20765608fa..a19bf698d9 100644 --- a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js +++ b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js @@ -3,10 +3,7 @@ import { AboutPreferences, PREFERENCES_LOADED_EVENT, } from "lib/AboutPreferences.sys.mjs"; -import { - actionTypes as at, - actionCreators as ac, -} from "common/Actions.sys.mjs"; +import { actionTypes as at, actionCreators as ac } from "common/Actions.mjs"; import { GlobalOverrider } from "test/unit/utils"; describe("AboutPreferences Feed", () => { diff --git a/browser/components/newtab/test/unit/lib/ActivityStream.test.js b/browser/components/newtab/test/unit/lib/ActivityStream.test.js index b9deba1069..7921ae2c91 100644 --- a/browser/components/newtab/test/unit/lib/ActivityStream.test.js +++ b/browser/components/newtab/test/unit/lib/ActivityStream.test.js @@ -1,4 +1,4 @@ -import { CONTENT_MESSAGE_TYPE } from "common/Actions.sys.mjs"; +import { CONTENT_MESSAGE_TYPE } from "common/Actions.mjs"; import { ActivityStream, PREFS_CONFIG } from "lib/ActivityStream.sys.mjs"; import { GlobalOverrider } from "test/unit/utils"; diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js index 4bea86331d..8df62b2903 100644 --- a/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js +++ b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { ActivityStreamMessageChannel, DEFAULT_OPTIONS, @@ -16,7 +13,7 @@ const OPTIONS = [ ]; // Create an object containing details about a tab as expected within -// the loaded tabs map in ActivityStreamMessageChannel.jsm. +// the loaded tabs map in ActivityStreamMessageChannel.sys.mjs. function getTabDetails(portID, url = "about:newtab", extraArgs = {}) { let actor = { portID, diff --git a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js index 92e10facb3..e10a4cbc04 100644 --- a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js +++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js @@ -2,7 +2,7 @@ import { actionCreators as ac, actionTypes as at, actionUtils as au, -} from "common/Actions.sys.mjs"; +} from "common/Actions.mjs"; import { combineReducers, createStore } from "redux"; import { GlobalOverrider } from "test/unit/utils"; import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.sys.mjs"; @@ -849,6 +849,8 @@ describe("DiscoveryStreamFeed", () => { spocs: { items: [{ id: "data" }] }, }); sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + const loadTimestamp = 100; + clock.tick(loadTimestamp); await feed.loadSpocs(feed.store.dispatch); @@ -860,15 +862,15 @@ describe("DiscoveryStreamFeed", () => { title: "", sponsor: "", sponsored_by_override: undefined, - items: [{ id: "data", score: 1 }], + items: [{ id: "data", score: 1, fetchTimestamp: loadTimestamp }], }, }, - lastUpdated: 0, + lastUpdated: loadTimestamp, }); assert.deepEqual( feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0], - { id: "data", score: 1 } + { id: "data", score: 1, fetchTimestamp: loadTimestamp } ); }); it("should normalizeSpocsItems for older spoc data", async () => { @@ -882,7 +884,7 @@ describe("DiscoveryStreamFeed", () => { assert.deepEqual( feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0], - { id: "data", score: 1 } + { id: "data", score: 1, fetchTimestamp: 0 } ); }); it("should dispatch DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE with feature_flags", async () => { @@ -936,7 +938,7 @@ describe("DiscoveryStreamFeed", () => { context: "", sponsor: "", sponsored_by_override: undefined, - items: [{ id: "data", score: 1 }], + items: [{ id: "data", score: 1, fetchTimestamp: 0 }], }, placement2: { title: "", @@ -978,7 +980,7 @@ describe("DiscoveryStreamFeed", () => { context: "context", sponsor: "", sponsored_by_override: undefined, - items: [{ id: "data", score: 1 }], + items: [{ id: "data", score: 1, fetchTimestamp: 0 }], }, }); }); @@ -3444,16 +3446,12 @@ describe("DiscoveryStreamFeed", () => { }, }); sandbox.stub(global.Region, "home").get(() => "DE"); - globals.set("NimbusFeatures", { - saveToPocket: { - getVariable: sandbox.stub(), - }, - }); - global.NimbusFeatures.saveToPocket.getVariable - .withArgs("bffApi") + sandbox.stub(global.Services.prefs, "getStringPref"); + global.Services.prefs.getStringPref + .withArgs("extensions.pocket.bffApi") .returns("bffApi"); - global.NimbusFeatures.saveToPocket.getVariable - .withArgs("oAuthConsumerKeyBff") + global.Services.prefs.getStringPref + .withArgs("extensions.pocket.oAuthConsumerKeyBff") .returns("oAuthConsumerKeyBff"); }); it("should return true with isBff", async () => { diff --git a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js index ac262baf90..5e2979893d 100644 --- a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js +++ b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js @@ -1,4 +1,4 @@ -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import { DownloadsManager } from "lib/DownloadsManager.sys.mjs"; import { GlobalOverrider } from "test/unit/utils"; diff --git a/browser/components/newtab/test/unit/lib/FaviconFeed.test.js b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js index e9be9b86ba..8b9cf24984 100644 --- a/browser/components/newtab/test/unit/lib/FaviconFeed.test.js +++ b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js @@ -1,6 +1,6 @@ "use strict"; import { FaviconFeed, fetchIconFromRedirects } from "lib/FaviconFeed.sys.mjs"; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import { GlobalOverrider } from "test/unit/utils"; const FAKE_ENDPOINT = "https://foo.com/"; diff --git a/browser/components/newtab/test/unit/lib/NewTabInit.test.js b/browser/components/newtab/test/unit/lib/NewTabInit.test.js index 68ab9d7821..0def9293f0 100644 --- a/browser/components/newtab/test/unit/lib/NewTabInit.test.js +++ b/browser/components/newtab/test/unit/lib/NewTabInit.test.js @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { NewTabInit } from "lib/NewTabInit.sys.mjs"; describe("NewTabInit", () => { diff --git a/browser/components/newtab/test/unit/lib/PrefsFeed.test.js b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js index 498c7198ab..8f33dce24f 100644 --- a/browser/components/newtab/test/unit/lib/PrefsFeed.test.js +++ b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { GlobalOverrider } from "test/unit/utils"; import { PrefsFeed } from "lib/PrefsFeed.sys.mjs"; diff --git a/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js index 9e68f4869a..05999be08d 100644 --- a/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js +++ b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js @@ -1,7 +1,4 @@ -import { - actionCreators as ac, - actionTypes as at, -} from "common/Actions.sys.mjs"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs"; import { combineReducers, createStore } from "redux"; import { reducers } from "common/Reducers.sys.mjs"; diff --git a/browser/components/newtab/test/unit/lib/SectionsManager.test.js b/browser/components/newtab/test/unit/lib/SectionsManager.test.js index b3a9abd70c..45c5b7c689 100644 --- a/browser/components/newtab/test/unit/lib/SectionsManager.test.js +++ b/browser/components/newtab/test/unit/lib/SectionsManager.test.js @@ -5,7 +5,7 @@ import { CONTENT_MESSAGE_TYPE, MAIN_MESSAGE_TYPE, PRELOAD_MESSAGE_TYPE, -} from "common/Actions.sys.mjs"; +} from "common/Actions.mjs"; import { EventEmitter, GlobalOverrider } from "test/unit/utils"; import { SectionsFeed, SectionsManager } from "lib/SectionsManager.sys.mjs"; diff --git a/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js index a0789b182e..f5ba73d2ea 100644 --- a/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js +++ b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js @@ -2,7 +2,7 @@ import { SYSTEM_TICK_INTERVAL, SystemTickFeed, } from "lib/SystemTickFeed.sys.mjs"; -import { actionTypes as at } from "common/Actions.sys.mjs"; +import { actionTypes as at } from "common/Actions.mjs"; import { GlobalOverrider } from "test/unit/utils"; describe("System Tick Feed", () => { diff --git a/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js b/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js index 31a03947cd..1cb8a44631 100644 --- a/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js +++ b/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js @@ -4,7 +4,7 @@ "use strict"; const { actionTypes: at } = ChromeUtils.importESModule( - "resource://activity-stream/common/Actions.sys.mjs" + "resource://activity-stream/common/Actions.mjs" ); ChromeUtils.defineESModuleGetters(this, { diff --git a/browser/components/newtab/test/xpcshell/test_PlacesFeed.js b/browser/components/newtab/test/xpcshell/test_PlacesFeed.js index 19f9e343f5..78dda7818e 100644 --- a/browser/components/newtab/test/xpcshell/test_PlacesFeed.js +++ b/browser/components/newtab/test/xpcshell/test_PlacesFeed.js @@ -4,7 +4,7 @@ "use strict"; const { actionTypes: at, actionCreators: ac } = ChromeUtils.importESModule( - "resource://activity-stream/common/Actions.sys.mjs" + "resource://activity-stream/common/Actions.mjs" ); ChromeUtils.defineESModuleGetters(this, { diff --git a/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js b/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js index 59d82f5583..354eac8c2a 100644 --- a/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js +++ b/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js @@ -4,11 +4,11 @@ "use strict"; const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( - "resource://activity-stream/common/Actions.sys.mjs" + "resource://activity-stream/common/Actions.mjs" ); const { MESSAGE_TYPE_HASH: msg } = ChromeUtils.importESModule( - "resource:///modules/asrouter/ActorConstants.sys.mjs" + "resource:///modules/asrouter/ActorConstants.mjs" ); const { updateAppInfo } = ChromeUtils.importESModule( @@ -947,18 +947,18 @@ add_task( } ); -add_task(async function test_applyWhatsNewPolicy() { +add_task(async function test_applyToolbarBadgePolicy() { info( - "TelemetryFeed.applyWhatsNewPolicy should set client_id and set pingType" + "TelemetryFeed.applyToolbarBadgePolicy should set client_id and set pingType" ); let instance = new TelemetryFeed(); - let { ping, pingType } = await instance.applyWhatsNewPolicy({}); + let { ping, pingType } = await instance.applyToolbarBadgePolicy({}); Assert.equal( ping.client_id, Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") ); - Assert.equal(pingType, "whats-new-panel"); + Assert.equal(pingType, "toolbar-badge"); }); add_task(async function test_applyInfoBarPolicy() { @@ -1288,10 +1288,10 @@ add_task(async function test_createASRouterEvent_call_correctPolicy() { message_id: "onboarding_message_01", }); - testCallCorrectPolicy("applyWhatsNewPolicy", { - action: "whats-new-panel_user_event", - event: "CLICK_BUTTON", - message_id: "whats-new-panel_message_01", + testCallCorrectPolicy("applyToolbarBadgePolicy", { + action: "badge_user_event", + event: "IMPRESSION", + message_id: "badge_message_01", }); testCallCorrectPolicy("applyMomentsPolicy", { @@ -2230,6 +2230,8 @@ add_task( const POS_1 = 1; const POS_2 = 4; const SHIM = "Y29uc2lkZXIgeW91ciBjdXJpb3NpdHkgcmV3YXJkZWQ="; + const FETCH_TIMESTAMP = new Date("March 22, 2024 10:15:20"); + const NEWTAB_CREATION_TIMESTAMP = new Date("March 23, 2024 11:10:30"); sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); let pingSubmitted = new Promise(resolve => { @@ -2252,6 +2254,14 @@ add_task( tile_id: String(2), }); Assert.equal(Glean.pocket.shim.testGetValue(), SHIM); + Assert.deepEqual( + Glean.pocket.fetchTimestamp.testGetValue(), + FETCH_TIMESTAMP + ); + Assert.deepEqual( + Glean.pocket.newtabCreationTimestamp.testGetValue(), + NEWTAB_CREATION_TIMESTAMP + ); resolve(); }); @@ -2272,10 +2282,12 @@ add_task( type: "spoc", recommendation_id: undefined, shim: SHIM, + fetchTimestamp: FETCH_TIMESTAMP.valueOf(), }, ], window_inner_width: 1000, window_inner_height: 900, + firstVisibleTimestamp: NEWTAB_CREATION_TIMESTAMP.valueOf(), }); await pingSubmitted; @@ -2949,6 +2961,8 @@ add_task( Services.fog.testResetFOG(); const ACTION_POSITION = 42; const SHIM = "Y29uc2lkZXIgeW91ciBjdXJpb3NpdHkgcmV3YXJkZWQ="; + const FETCH_TIMESTAMP = new Date("March 22, 2024 10:15:20"); + const NEWTAB_CREATION_TIMESTAMP = new Date("March 23, 2024 11:10:30"); let action = ac.DiscoveryStreamUserEvent({ event: "CLICK", action_position: ACTION_POSITION, @@ -2957,6 +2971,8 @@ add_task( recommendation_id: undefined, tile_id: 448685088, shim: SHIM, + fetchTimestamp: FETCH_TIMESTAMP.valueOf(), + firstVisibleTimestamp: NEWTAB_CREATION_TIMESTAMP.valueOf(), }, }); @@ -2966,6 +2982,14 @@ add_task( let pingSubmitted = new Promise(resolve => { GleanPings.spoc.testBeforeNextSubmit(reason => { Assert.equal(reason, "click"); + Assert.deepEqual( + Glean.pocket.fetchTimestamp.testGetValue(), + FETCH_TIMESTAMP + ); + Assert.deepEqual( + Glean.pocket.newtabCreationTimestamp.testGetValue(), + NEWTAB_CREATION_TIMESTAMP + ); resolve(); }); }); @@ -3043,6 +3067,8 @@ add_task( Services.fog.testResetFOG(); const ACTION_POSITION = 42; const SHIM = "Y29uc2lkZXIgeW91ciBjdXJpb3NpdHkgcmV3YXJkZWQ="; + const FETCH_TIMESTAMP = new Date("March 22, 2024 10:15:20"); + const NEWTAB_CREATION_TIMESTAMP = new Date("March 23, 2024 11:10:30"); let action = ac.DiscoveryStreamUserEvent({ event: "SAVE_TO_POCKET", action_position: ACTION_POSITION, @@ -3051,6 +3077,8 @@ add_task( recommendation_id: undefined, tile_id: 448685088, shim: SHIM, + fetchTimestamp: FETCH_TIMESTAMP.valueOf(), + newtabCreationTimestamp: NEWTAB_CREATION_TIMESTAMP.valueOf(), }, }); @@ -3064,6 +3092,14 @@ add_task( SHIM, "Pocket shim was recorded" ); + Assert.deepEqual( + Glean.pocket.fetchTimestamp.testGetValue(), + FETCH_TIMESTAMP + ); + Assert.deepEqual( + Glean.pocket.newtabCreationTimestamp.testGetValue(), + NEWTAB_CREATION_TIMESTAMP + ); resolve(); }); diff --git a/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js b/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js index 860e8758a5..4be520fcca 100644 --- a/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js +++ b/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js @@ -8,7 +8,7 @@ const { TopSitesFeed, DEFAULT_TOP_SITES } = ChromeUtils.importESModule( ); const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( - "resource://activity-stream/common/Actions.sys.mjs" + "resource://activity-stream/common/Actions.mjs" ); ChromeUtils.defineESModuleGetters(this, { diff --git a/browser/components/newtab/test/xpcshell/test_WallpaperFeed.js b/browser/components/newtab/test/xpcshell/test_WallpaperFeed.js new file mode 100644 index 0000000000..c6c12c17bf --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_WallpaperFeed.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WallpaperFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/WallpaperFeed.sys.mjs" +); + +const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + Utils: "resource://services-settings/Utils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const PREF_WALLPAPERS_ENABLED = + "browser.newtabpage.activity-stream.newtabWallpapers.enabled"; + +add_task(async function test_construction() { + let feed = new WallpaperFeed(); + + info("WallpaperFeed constructor should create initial values"); + + Assert.ok(feed, "Could construct a WallpaperFeed"); + Assert.ok(feed.loaded === false, "WallpaperFeed is not loaded"); + Assert.ok( + feed.wallpaperClient === "", + "wallpaperClient is initialized as an empty string" + ); + Assert.ok( + feed.wallpaperDB === "", + "wallpaperDB is initialized as an empty string" + ); + Assert.ok( + feed.baseAttachmentURL === "", + "baseAttachmentURL is initialized as an empty string" + ); +}); + +add_task(async function test_onAction_INIT() { + let sandbox = sinon.createSandbox(); + let feed = new WallpaperFeed(); + Services.prefs.setBoolPref(PREF_WALLPAPERS_ENABLED, true); + const attachment = { + attachment: { + location: "attachment", + }, + }; + sandbox.stub(feed, "RemoteSettings").returns({ + get: () => [attachment], + on: () => {}, + }); + sandbox.stub(Utils, "SERVER_URL").returns("http://localhost:8888/v1"); + feed.store = { + dispatch: sinon.spy(), + }; + sandbox.stub(feed, "fetch").resolves({ + json: () => ({ + capabilities: { + attachments: { + base_url: "http://localhost:8888/base_url/", + }, + }, + }), + }); + + info("WallpaperFeed.onAction INIT should initialize wallpapers"); + + await feed.onAction({ + type: at.INIT, + }); + + Assert.ok(feed.store.dispatch.calledOnce); + Assert.ok( + feed.store.dispatch.calledWith( + ac.BroadcastToContent({ + type: at.WALLPAPERS_SET, + data: [ + { + ...attachment, + wallpaperUrl: "http://localhost:8888/base_url/attachment", + }, + ], + meta: { + isStartup: true, + }, + }) + ) + ); + Services.prefs.clearUserPref(PREF_WALLPAPERS_ENABLED); + sandbox.restore(); +}); + +add_task(async function test_onAction_PREF_CHANGED() { + let sandbox = sinon.createSandbox(); + let feed = new WallpaperFeed(); + Services.prefs.setBoolPref(PREF_WALLPAPERS_ENABLED, true); + sandbox.stub(feed, "wallpaperSetup").returns(); + + info("WallpaperFeed.onAction PREF_CHANGED should call wallpaperSetup"); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "newtabWallpapers.enabled" }, + }); + + Assert.ok(feed.wallpaperSetup.calledOnce); + Assert.ok(feed.wallpaperSetup.calledWith(false)); + + Services.prefs.clearUserPref(PREF_WALLPAPERS_ENABLED); + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/xpcshell.toml b/browser/components/newtab/test/xpcshell/xpcshell.toml index 87d73669d3..13c11b0541 100644 --- a/browser/components/newtab/test/xpcshell/xpcshell.toml +++ b/browser/components/newtab/test/xpcshell/xpcshell.toml @@ -26,3 +26,5 @@ support-files = ["../schemas/*.schema.json"] ["test_TopSitesFeed.js"] ["test_TopSitesFeed_glean.js"] + +["test_WallpaperFeed.js"] diff --git a/browser/components/newtab/webpack.system-addon.config.js b/browser/components/newtab/webpack.system-addon.config.js index a0400ec39e..68a384ea71 100644 --- a/browser/components/newtab/webpack.system-addon.config.js +++ b/browser/components/newtab/webpack.system-addon.config.js @@ -48,7 +48,7 @@ module.exports = (env = {}) => ({ }, // This resolve config allows us to import with paths relative to the root directory, e.g. "lib/ActivityStream.sys.mjs" resolve: { - extensions: [".js", ".jsx"], + extensions: [".js", ".jsx", ".mjs"], modules: ["node_modules", "."], }, externals: { diff --git a/browser/components/originattributes/test/browser/browser.toml b/browser/components/originattributes/test/browser/browser.toml index 5585b2a914..59c9d1c3c6 100644 --- a/browser/components/originattributes/test/browser/browser.toml +++ b/browser/components/originattributes/test/browser/browser.toml @@ -32,7 +32,7 @@ support-files = [ "file_thirdPartyChild.script.js", "file_thirdPartyChild.sharedworker.js", "file_thirdPartyChild.track.vtt", - "file_thirdPartyChild.video.ogv", + "file_thirdPartyChild.video.webm", "file_thirdPartyChild.worker.fetch.html", "file_thirdPartyChild.worker.js", "file_thirdPartyChild.worker.request.html", diff --git a/browser/components/originattributes/test/browser/browser_cache.js b/browser/components/originattributes/test/browser/browser_cache.js index ea8f0fe803..4c2369fc00 100644 --- a/browser/components/originattributes/test/browser/browser_cache.js +++ b/browser/components/originattributes/test/browser/browser_cache.js @@ -28,7 +28,7 @@ let suffixes = [ "xhr.html", "worker.xhr.html", "audio.ogg", - "video.ogv", + "video.webm", "track.vtt", "fetch.html", "worker.fetch.html", @@ -56,7 +56,7 @@ function cacheDataForContext(loadContextInfo) { return new Promise(resolve => { let cacheEntries = []; let cacheVisitor = { - onCacheStorageInfo(num, consumption) {}, + onCacheStorageInfo() {}, onCacheEntryInfo(uri, idEnhance) { cacheEntries.push({ uri, idEnhance }); }, @@ -176,7 +176,7 @@ async function doTest(aBrowser) { await SpecialPowers.spawn(aBrowser, [argObj], async function (arg) { content.windowUtils.clearSharedStyleSheetCache(); - let videoURL = arg.urlPrefix + "file_thirdPartyChild.video.ogv"; + let videoURL = arg.urlPrefix + "file_thirdPartyChild.video.webm"; let audioURL = arg.urlPrefix + "file_thirdPartyChild.audio.ogg"; let trackURL = arg.urlPrefix + "file_thirdPartyChild.track.vtt"; let URLSuffix = "?r=" + arg.randomSuffix; @@ -257,7 +257,7 @@ async function doTest(aBrowser) { } // The check function, which checks the number of cache entries. -async function doCheck(aShouldIsolate, aInputA, aInputB) { +async function doCheck(aShouldIsolate) { let expectedEntryCount = 1; let data = []; data = data.concat( diff --git a/browser/components/originattributes/test/browser/browser_favicon_firstParty.js b/browser/components/originattributes/test/browser/browser_favicon_firstParty.js index 300c2f9f25..0a3bf39886 100644 --- a/browser/components/originattributes/test/browser/browser_favicon_firstParty.js +++ b/browser/components/originattributes/test/browser/browser_favicon_firstParty.js @@ -56,7 +56,7 @@ function clearAllPlacesFavicons() { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if (aTopic === "places-favicons-expired") { resolve(); Services.obs.removeObserver(observer, "places-favicons-expired"); @@ -77,7 +77,7 @@ function observeFavicon(aFirstPartyDomain, aExpectedCookie, aPageURI) { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { // Make sure that the topic is 'http-on-modify-request'. if (aTopic === "http-on-modify-request") { // We check the firstPartyDomain for the originAttributes of the loading @@ -136,7 +136,7 @@ function observeFavicon(aFirstPartyDomain, aExpectedCookie, aPageURI) { function waitOnFaviconResponse(aFaviconURL) { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if ( aTopic === "http-on-examine-response" || aTopic === "http-on-examine-cached-response" diff --git a/browser/components/originattributes/test/browser/browser_favicon_userContextId.js b/browser/components/originattributes/test/browser/browser_favicon_userContextId.js index 2f0a5d06a9..e8e687d938 100644 --- a/browser/components/originattributes/test/browser/browser_favicon_userContextId.js +++ b/browser/components/originattributes/test/browser/browser_favicon_userContextId.js @@ -57,7 +57,7 @@ function clearAllPlacesFavicons() { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if (aTopic === "places-favicons-expired") { resolve(); Services.obs.removeObserver(observer, "places-favicons-expired"); @@ -80,7 +80,7 @@ function FaviconObserver( } FaviconObserver.prototype = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { // Make sure that the topic is 'http-on-modify-request'. if (aTopic === "http-on-modify-request") { // We check the userContextId for the originAttributes of the loading diff --git a/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js b/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js index 0266765782..450676eba2 100644 --- a/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js +++ b/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js @@ -15,7 +15,7 @@ const TEST_ORIGIN = `http://${TEST_FIRST_PARTY}`; const TEST_BASE_PATH = "/browser/browser/components/originattributes/test/browser/"; const TEST_PATH = `${TEST_BASE_PATH}file_saveAs.sjs`; -const TEST_PATH_VIDEO = `${TEST_BASE_PATH}file_thirdPartyChild.video.ogv`; +const TEST_PATH_VIDEO = `${TEST_BASE_PATH}file_thirdPartyChild.video.webm`; const TEST_PATH_IMAGE = `${TEST_BASE_PATH}file_favicon.png`; // For the "Save Page As" test, we will check the channel of the sub-resource @@ -284,7 +284,7 @@ add_task(async function testPageInfoMediaSaveAs() { ); info("Open the media panel of the pageinfo."); - let pageInfo = BrowserPageInfo( + let pageInfo = BrowserCommands.pageInfo( gBrowser.selectedBrowser.currentURI.spec, "mediaTab" ); diff --git a/browser/components/originattributes/test/browser/browser_httpauth.js b/browser/components/originattributes/test/browser/browser_httpauth.js index b2e95e13ac..8821f3a92b 100644 --- a/browser/components/originattributes/test/browser/browser_httpauth.js +++ b/browser/components/originattributes/test/browser/browser_httpauth.js @@ -2,10 +2,6 @@ let { HttpServer } = ChromeUtils.importESModule( "resource://testing-common/httpd.sys.mjs" ); -let authPromptModalType = Services.prefs.getIntPref( - "prompts.modalType.httpAuth" -); - let server = new HttpServer(); server.registerPathHandler("/file.html", fileHandler); server.start(-1); @@ -57,7 +53,7 @@ function getResult() { return credentialQueue.shift(); } -async function doInit(aMode) { +async function doInit() { await SpecialPowers.pushPrefEnv({ set: [["privacy.partition.network_state", false]], }); diff --git a/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js b/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js index 34c77f746d..c826ec07d4 100644 --- a/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js +++ b/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js @@ -72,12 +72,12 @@ async function doBefore() { } // the test function does nothing on purpose. -function doTest(aBrowser) { +function doTest() { return 0; } // the check function -function doCheck(shouldIsolate, a, b) { +function doCheck(shouldIsolate) { // if we're doing first party isolation and the image cache isolation is // working, then gHits should be 2 because the image would have been loaded // one per first party domain. if first party isolation is disabled, then diff --git a/browser/components/originattributes/test/browser/browser_sanitize.js b/browser/components/originattributes/test/browser/browser_sanitize.js index 61d236f249..0256a91eb7 100644 --- a/browser/components/originattributes/test/browser/browser_sanitize.js +++ b/browser/components/originattributes/test/browser/browser_sanitize.js @@ -20,8 +20,8 @@ function cacheDataForContext(loadContextInfo) { return new Promise(resolve => { let cachedURIs = []; let cacheVisitor = { - onCacheStorageInfo(num, consumption) {}, - onCacheEntryInfo(uri, idEnhance) { + onCacheStorageInfo() {}, + onCacheEntryInfo(uri) { cachedURIs.push(uri.asciiSpec); }, onCacheEntryVisitCompleted() { diff --git a/browser/components/originattributes/test/browser/file_saveAs.sjs b/browser/components/originattributes/test/browser/file_saveAs.sjs index 9b16250c76..8e1cc60dae 100644 --- a/browser/components/originattributes/test/browser/file_saveAs.sjs +++ b/browser/components/originattributes/test/browser/file_saveAs.sjs @@ -2,8 +2,8 @@ const HTTP_ORIGIN = "http://example.com"; const SECOND_ORIGIN = "http://example.org"; const URI_PATH = "/browser/browser/components/originattributes/test/browser/"; const LINK_PATH = `${URI_PATH}file_saveAs.sjs`; -// Reusing existing ogv file for testing. -const VIDEO_PATH = `${URI_PATH}file_thirdPartyChild.video.ogv`; +// Reusing existing webm file for testing. +const VIDEO_PATH = `${URI_PATH}file_thirdPartyChild.video.webm`; // Reusing existing png file for testing. const IMAGE_PATH = `${URI_PATH}file_favicon.png`; const FRAME_PATH = `${SECOND_ORIGIN}${URI_PATH}file_saveAs.sjs?image=1`; diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv b/browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv Binary files differdeleted file mode 100644 index 68dee3cf2b..0000000000 --- a/browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv +++ /dev/null diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.video.webm b/browser/components/originattributes/test/browser/file_thirdPartyChild.video.webm Binary files differnew file mode 100644 index 0000000000..5ad699fc1a --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.video.webm diff --git a/browser/components/originattributes/test/browser/head.js b/browser/components/originattributes/test/browser/head.js index bd4307fd1c..1d3dfd563e 100644 --- a/browser/components/originattributes/test/browser/head.js +++ b/browser/components/originattributes/test/browser/head.js @@ -448,7 +448,7 @@ this.IsolationTestTools = { // is finished before the next round of testing. if (SpecialPowers.useRemoteSubframes) { await new Promise(resolve => { - let observer = (subject, topic, data) => { + let observer = (subject, topic) => { if (topic === "ipc:content-shutdown") { Services.obs.removeObserver(observer, "ipc:content-shutdown"); resolve(); diff --git a/browser/components/pagedata/.eslintrc.js b/browser/components/pagedata/.eslintrc.js index 8ead689bcc..aac2436d20 100644 --- a/browser/components/pagedata/.eslintrc.js +++ b/browser/components/pagedata/.eslintrc.js @@ -5,8 +5,6 @@ "use strict"; module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], - rules: { "mozilla/var-only-at-top-level": "error", "no-unused-expressions": "error", diff --git a/browser/components/pagedata/PageDataService.sys.mjs b/browser/components/pagedata/PageDataService.sys.mjs index 7160705c27..3cc93ead39 100644 --- a/browser/components/pagedata/PageDataService.sys.mjs +++ b/browser/components/pagedata/PageDataService.sys.mjs @@ -10,7 +10,6 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", - E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", HiddenFrame: "resource://gre/modules/HiddenFrame.sys.mjs", }); @@ -542,19 +541,8 @@ export const PageDataService = new (class PageDataService extends EventEmitter { this.#backgroundBrowsers.set(browser, resolve); let principal = Services.scriptSecurityManager.getSystemPrincipal(); - let oa = lazy.E10SUtils.predictOriginAttributes({ - browser, - }); let loadURIOptions = { triggeringPrincipal: principal, - remoteType: lazy.E10SUtils.getRemoteTypeForURI( - url, - true, - false, - lazy.E10SUtils.DEFAULT_REMOTE_TYPE, - null, - oa - ), }; browser.fixupAndLoadURIString(url, loadURIOptions); @@ -573,10 +561,8 @@ export const PageDataService = new (class PageDataService extends EventEmitter { * The notification's subject. * @param {string} topic * The notification topic. - * @param {string} data - * The data associated with the notification. */ - observe(subject, topic, data) { + observe(subject, topic) { switch (topic) { case "idle": lazy.logConsole.debug("User went idle"); diff --git a/browser/components/places/.eslintrc.js b/browser/components/places/.eslintrc.js deleted file mode 100644 index 9aafb4a214..0000000000 --- a/browser/components/places/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -/* 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"; - -module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], -}; diff --git a/browser/components/places/content/places.js b/browser/components/places/content/places.js index 685fa12b51..9e2abaafcc 100644 --- a/browser/components/places/content/places.js +++ b/browser/components/places/content/places.js @@ -1168,7 +1168,7 @@ var ViewMenu = { menuitem.setAttribute("type", "radio"); menuitem.setAttribute("name", "columns"); // This column is the sort key. Its item is checked. - if (column.getAttribute("sortDirection") != "") { + if (column.hasAttribute("sortDirection")) { menuitem.setAttribute("checked", "true"); } } else if (type == "checkbox") { diff --git a/browser/components/places/content/places.xhtml b/browser/components/places/content/places.xhtml index e1ac09878b..d0e6a65eb5 100644 --- a/browser/components/places/content/places.xhtml +++ b/browser/components/places/content/places.xhtml @@ -15,6 +15,10 @@ onunload="PlacesOrganizer.destroy();" width="800" height="500" screenX="10" screenY="10" +#ifdef XP_MACOSX + drawtitle="true" + chromemargin="0,0,0,0" +#endif toggletoolbar="true" persist="width height screenX screenY sizemode"> diff --git a/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js b/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js index 16aeb08ad8..228fea654e 100644 --- a/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js +++ b/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js @@ -131,14 +131,14 @@ let checkContextMenu = async (cbfunc, optionItems, doc = document) => { if (expectedOptionItems.includes("placesContext_open")) { Assert.equal( doc.getElementById("placesContext_open").getAttribute("default"), - loadBookmarksInNewTab ? "" : "true", + loadBookmarksInNewTab ? null : "true", `placesContext_open has the correct "default" attribute when loadBookmarksInTabs = ${loadBookmarksInNewTab}` ); } if (expectedOptionItems.includes("placesContext_open:newtab")) { Assert.equal( doc.getElementById("placesContext_open:newtab").getAttribute("default"), - loadBookmarksInNewTab ? "true" : "", + loadBookmarksInNewTab ? "true" : null, `placesContext_open:newtab has the correct "default" attribute when loadBookmarksInTabs = ${loadBookmarksInNewTab}` ); } diff --git a/browser/components/places/tests/browser/browser_sidebarpanels_click.js b/browser/components/places/tests/browser/browser_sidebarpanels_click.js index 3e5b1c6ec6..9a1b039e78 100644 --- a/browser/components/places/tests/browser/browser_sidebarpanels_click.js +++ b/browser/components/places/tests/browser/browser_sidebarpanels_click.js @@ -157,16 +157,10 @@ function promiseAlertDialogObserved() { async function observer(subject) { info("alert dialog observed as expected"); Services.obs.removeObserver(observer, "common-dialog-loaded"); - Services.obs.removeObserver(observer, "tabmodal-dialog-loaded"); - if (subject.Dialog) { - subject.Dialog.ui.button0.click(); - } else { - subject.querySelector(".tabmodalprompt-button0").click(); - } + subject.Dialog.ui.button0.click(); resolve(); } Services.obs.addObserver(observer, "common-dialog-loaded"); - Services.obs.addObserver(observer, "tabmodal-dialog-loaded"); }); } diff --git a/browser/components/places/tests/browser/head.js b/browser/components/places/tests/browser/head.js index bcd89bce15..5459e6f924 100644 --- a/browser/components/places/tests/browser/head.js +++ b/browser/components/places/tests/browser/head.js @@ -194,7 +194,7 @@ function promiseSetToolbarVisibility(aToolbar, aVisible) { function isToolbarVisible(aToolbar) { let hidingAttribute = aToolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; - let hidingValue = aToolbar.getAttribute(hidingAttribute).toLowerCase(); + let hidingValue = aToolbar.getAttribute(hidingAttribute)?.toLowerCase(); // Check for both collapsed="true" and collapsed="collapsed" return hidingValue !== "true" && hidingValue !== hidingAttribute; } diff --git a/browser/components/pocket/content/SaveToPocket.sys.mjs b/browser/components/pocket/content/SaveToPocket.sys.mjs index 60674acc82..9b9a30b4a2 100644 --- a/browser/components/pocket/content/SaveToPocket.sys.mjs +++ b/browser/components/pocket/content/SaveToPocket.sys.mjs @@ -101,7 +101,7 @@ export var SaveToPocket = { ); }, - observe(subject, topic, data) { + observe(subject, topic) { if (topic == "browser-delayed-startup-finished") { // We only get here if pocket is disabled; the observer is removed when // we're enabled. diff --git a/browser/components/pocket/content/panels/js/components/Home/Home.jsx b/browser/components/pocket/content/panels/js/components/Home/Home.jsx index 1036876725..8e15df1869 100644 --- a/browser/components/pocket/content/panels/js/components/Home/Home.jsx +++ b/browser/components/pocket/content/panels/js/components/Home/Home.jsx @@ -32,7 +32,7 @@ function Home(props) { : `` }`; - const loadingRecentSaves = useCallback(resp => { + const loadingRecentSaves = useCallback(() => { setArticlesState(prevState => ({ ...prevState, status: "loading", diff --git a/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx b/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx index 502c73b0a5..b750946234 100644 --- a/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx +++ b/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx @@ -85,7 +85,7 @@ function Saved(props) { panelMessaging.addMessageListener( "PKT_getArticleInfoAttempted", - function (resp) { + function () { setArticleInfoAttempted(true); } ); diff --git a/browser/components/pocket/content/panels/js/home/overlay.jsx b/browser/components/pocket/content/panels/js/home/overlay.jsx index 4d49a09470..f8ee1578ba 100644 --- a/browser/components/pocket/content/panels/js/home/overlay.jsx +++ b/browser/components/pocket/content/panels/js/home/overlay.jsx @@ -7,7 +7,7 @@ import React from "react"; import ReactDOM from "react-dom"; import Home from "../components/Home/Home.jsx"; -var HomeOverlay = function (options) { +var HomeOverlay = function () { this.inited = false; this.active = false; }; diff --git a/browser/components/pocket/content/panels/js/main.bundle.js b/browser/components/pocket/content/panels/js/main.bundle.js index 36e2f82973..39a3dbccd7 100644 --- a/browser/components/pocket/content/panels/js/main.bundle.js +++ b/browser/components/pocket/content/panels/js/main.bundle.js @@ -275,7 +275,7 @@ function Home(props) { status: "" }); const utmParams = `utm_source=${utmSource}${utmCampaign && utmContent ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` : ``}`; - const loadingRecentSaves = (0,react.useCallback)(resp => { + const loadingRecentSaves = (0,react.useCallback)(() => { setArticlesState(prevState => ({ ...prevState, status: "loading" @@ -381,7 +381,7 @@ It does not contain any logic for saving or communication with the extension or -var HomeOverlay = function (options) { +var HomeOverlay = function () { this.inited = false; this.active = false; }; @@ -487,7 +487,7 @@ It does not contain any logic for saving or communication with the extension or -var SignupOverlay = function (options) { +var SignupOverlay = function () { this.inited = false; this.active = false; this.create = function ({ @@ -772,7 +772,7 @@ function Saved(props) { messages.addMessageListener("PKT_articleInfoFetched", function (resp) { setSavedStoryState(resp?.data?.item_preview); }); - messages.addMessageListener("PKT_getArticleInfoAttempted", function (resp) { + messages.addMessageListener("PKT_getArticleInfoAttempted", function () { setArticleInfoAttempted(true); }); @@ -843,7 +843,7 @@ It does not contain any logic for saving or communication with the extension or -var SavedOverlay = function (options) { +var SavedOverlay = function () { this.inited = false; this.active = false; }; @@ -883,7 +883,7 @@ SavedOverlay.prototype = { -var StyleGuideOverlay = function (options) {}; +var StyleGuideOverlay = function () {}; StyleGuideOverlay.prototype = { create() { // TODO: Wrap popular topics component in JSX to work without needing an explicit container hierarchy for styling @@ -1072,7 +1072,7 @@ PKT_PANEL.prototype = { const config = { attributes: false, childList: true, subtree: true }; // Callback function to execute when mutations are observed - const callback = (mutationList, observer) => { + const callback = mutationList => { mutationList.forEach(mutation => { switch (mutation.type) { case "childList": { diff --git a/browser/components/pocket/content/panels/js/main.mjs b/browser/components/pocket/content/panels/js/main.mjs index b5ae0e9c3a..2c1da9528c 100644 --- a/browser/components/pocket/content/panels/js/main.mjs +++ b/browser/components/pocket/content/panels/js/main.mjs @@ -86,7 +86,7 @@ PKT_PANEL.prototype = { const config = { attributes: false, childList: true, subtree: true }; // Callback function to execute when mutations are observed - const callback = (mutationList, observer) => { + const callback = mutationList => { mutationList.forEach(mutation => { switch (mutation.type) { case "childList": { diff --git a/browser/components/pocket/content/panels/js/saved/overlay.jsx b/browser/components/pocket/content/panels/js/saved/overlay.jsx index ab2617f112..091821c149 100644 --- a/browser/components/pocket/content/panels/js/saved/overlay.jsx +++ b/browser/components/pocket/content/panels/js/saved/overlay.jsx @@ -7,7 +7,7 @@ import React from "react"; import ReactDOM from "react-dom"; import Saved from "../components/Saved/Saved.jsx"; -var SavedOverlay = function (options) { +var SavedOverlay = function () { this.inited = false; this.active = false; }; diff --git a/browser/components/pocket/content/panels/js/signup/overlay.jsx b/browser/components/pocket/content/panels/js/signup/overlay.jsx index 6143afbc83..ce3b681f15 100644 --- a/browser/components/pocket/content/panels/js/signup/overlay.jsx +++ b/browser/components/pocket/content/panels/js/signup/overlay.jsx @@ -8,7 +8,7 @@ import ReactDOM from "react-dom"; import pktPanelMessaging from "../messages.mjs"; import Signup from "../components/Signup/Signup.jsx"; -var SignupOverlay = function (options) { +var SignupOverlay = function () { this.inited = false; this.active = false; diff --git a/browser/components/pocket/content/panels/js/style-guide/overlay.jsx b/browser/components/pocket/content/panels/js/style-guide/overlay.jsx index fbc0dac069..b802a9159b 100644 --- a/browser/components/pocket/content/panels/js/style-guide/overlay.jsx +++ b/browser/components/pocket/content/panels/js/style-guide/overlay.jsx @@ -6,7 +6,7 @@ import Button from "../components/Button/Button.jsx"; import PopularTopics from "../components/PopularTopics/PopularTopics.jsx"; import TagPicker from "../components/TagPicker/TagPicker.jsx"; -var StyleGuideOverlay = function (options) {}; +var StyleGuideOverlay = function () {}; StyleGuideOverlay.prototype = { create() { diff --git a/browser/components/pocket/content/pktApi.sys.mjs b/browser/components/pocket/content/pktApi.sys.mjs index 16d0948b36..132f9369ce 100644 --- a/browser/components/pocket/content/pktApi.sys.mjs +++ b/browser/components/pocket/content/pktApi.sys.mjs @@ -47,7 +47,6 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", - NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); @@ -291,12 +290,12 @@ export var pktApi = (function () { "extensions.pocket.oAuthConsumerKey" ); } else { - baseAPIUrl = `https://${lazy.NimbusFeatures.saveToPocket.getVariable( - "bffApi" + baseAPIUrl = `https://${Services.prefs.getStringPref( + "extensions.pocket.bffApi" )}/desktop/v1`; - oAuthConsumerKey = lazy.NimbusFeatures.saveToPocket.getVariable( - "oAuthConsumerKeyBff" + oAuthConsumerKey = Services.prefs.getStringPref( + "extensions.pocket.oAuthConsumerKeyBff" ); } @@ -309,7 +308,7 @@ export var pktApi = (function () { data.locale_lang = Services.locale.appLocaleAsBCP47; data.consumer_key = oAuthConsumerKey; - var request = new XMLHttpRequest(); + var request = new XMLHttpRequest({ mozAnon: false }); if (!useBFF) { request.open("POST", url, true); @@ -317,7 +316,7 @@ export var pktApi = (function () { request.open("GET", url, true); } - request.onreadystatechange = function (e) { + request.onreadystatechange = function () { if (request.readyState == 4) { // "done" is a completed XHR regardless of success/error: if (options.done) { @@ -487,7 +486,7 @@ export var pktApi = (function () { access_token: getAccessToken(), url, }, - success(data) { + success() { if (options.success) { options.success.apply(options, Array.apply(null, arguments)); } @@ -508,7 +507,7 @@ export var pktApi = (function () { data: { access_token: getAccessToken(), }, - success(data) { + success() { if (options.success) { options.success.apply(options, Array.apply(null, arguments)); } @@ -761,8 +760,9 @@ export var pktApi = (function () { access_token: getAccessToken(), }); - const useBFF = - lazy.NimbusFeatures.saveToPocket.getVariable("bffRecentSaves"); + const useBFF = Services.prefs.getBoolPref( + "extensions.pocket.bffRecentSaves" + ); return apiRequest( { @@ -816,8 +816,9 @@ export var pktApi = (function () { { count: 4 }, { success(data) { - const useBFF = - lazy.NimbusFeatures.saveToPocket.getVariable("bffRecentSaves"); + const useBFF = Services.prefs.getBoolPref( + "extensions.pocket.bffRecentSaves" + ); // Don't try to parse bad or missing data if ( diff --git a/browser/components/pocket/content/pktUI.js b/browser/components/pocket/content/pktUI.js index 60b7e3bcc3..05e31b5c30 100644 --- a/browser/components/pocket/content/pktUI.js +++ b/browser/components/pocket/content/pktUI.js @@ -46,7 +46,6 @@ ChromeUtils.defineESModuleGetters(this, { ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", - NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", pktApi: "chrome://pocket/content/pktApi.sys.mjs", pktTelemetry: "chrome://pocket/content/pktTelemetry.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", @@ -125,11 +124,13 @@ var pktUI = (function () { * Show the sign-up panel */ function showSignUp() { - getFirefoxAccountSignedInUser(function (userdata) { + getFirefoxAccountSignedInUser(function () { showPanel( "about:pocket-signup?" + "emailButton=" + - NimbusFeatures.saveToPocket.getVariable("emailButton"), + Services.prefs.getBoolPref( + "extensions.pocket.refresh.emailButton.enabled" + ), `signup` ); }); @@ -154,8 +155,9 @@ var pktUI = (function () { * Show the Pocket home panel state */ function showPocketHome() { - const hideRecentSaves = - NimbusFeatures.saveToPocket.getVariable("hideRecentSaves"); + const hideRecentSaves = Services.prefs.getBoolPref( + "extensions.pocket.refresh.hideRecentSaves.enabled" + ); const locale = getUILocale(); let panel = `home_no_topics`; if (locale.startsWith("en-")) { @@ -232,7 +234,11 @@ var pktUI = (function () { async function onShowHome() { pktTelemetry.submitPocketButtonPing("click", "home_button"); - if (!NimbusFeatures.saveToPocket.getVariable("hideRecentSaves")) { + if ( + !Services.prefs.getBoolPref( + "extensions.pocket.refresh.hideRecentSaves.enabled" + ) + ) { let recentSaves = await pktApi.getRecentSavesCache(); if (recentSaves) { // We have cache, so we can use those. @@ -284,7 +290,7 @@ var pktUI = (function () { // Add url var options = { - success(data, request) { + success(data) { var item = data.item; var ho2 = data.ho2; var accountState = data.account_state; @@ -299,7 +305,11 @@ var pktUI = (function () { pktUIMessaging.sendMessageToPanel(saveLinkMessageId, successResponse); SaveToPocket.itemSaved(); - if (!NimbusFeatures.saveToPocket.getVariable("hideRecentSaves")) { + if ( + !Services.prefs.getBoolPref( + "extensions.pocket.refresh.hideRecentSaves.enabled" + ) + ) { // Articles saved for the first time (by anyone) won't have a resolved_id if (item?.resolved_id && item?.resolved_id !== "0") { pktApi.getArticleInfo(item.resolved_url, { @@ -493,7 +503,7 @@ var pktUI = (function () { .then(userData => { callback(userData); }) - .then(null, error => { + .then(null, () => { callback(); }); } diff --git a/browser/components/pocket/test/browser_pocket_button_icon_state.js b/browser/components/pocket/test/browser_pocket_button_icon_state.js index c2cba8133b..65c1608b9d 100644 --- a/browser/components/pocket/test/browser_pocket_button_icon_state.js +++ b/browser/components/pocket/test/browser_pocket_button_icon_state.js @@ -72,10 +72,14 @@ function checkPanelClosed() { let pocketButton = document.getElementById("save-to-pocket-button"); // Something should have closed the Pocket panel, icon should no longer be red. is(pocketButton.open, false, "Pocket button is closed"); - is(pocketButton.getAttribute("pocketed"), "", "Pocket item is not pocketed"); + is( + pocketButton.getAttribute("pocketed"), + null, + "Pocket item is not pocketed" + ); } -test_runner(async function test_pocketButtonState_changeTabs({ sandbox }) { +test_runner(async function test_pocketButtonState_changeTabs() { let tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, "https://example.com/browser/browser/components/pocket/test/test.html" @@ -101,7 +105,7 @@ test_runner(async function test_pocketButtonState_changeTabs({ sandbox }) { BrowserTestUtils.removeTab(tab); }); -test_runner(async function test_pocketButtonState_changeLocation({ sandbox }) { +test_runner(async function test_pocketButtonState_changeLocation() { let tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, "https://example.com/browser/browser/components/pocket/test/test.html" diff --git a/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js b/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js index 5abe3b3db1..4b6cc8f7ad 100644 --- a/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js +++ b/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js @@ -78,9 +78,7 @@ test_runner(async function test_AboutPocketParent_sendResponseMessageToPanel({ }); test_runner( - async function test_AboutPocketParent_receiveMessage_PKT_show_signup({ - sandbox, - }) { + async function test_AboutPocketParent_receiveMessage_PKT_show_signup() { await aboutPocketParent.receiveMessage({ name: "PKT_show_signup", }); @@ -96,9 +94,7 @@ test_runner( ); test_runner( - async function test_AboutPocketParent_receiveMessage_PKT_show_saved({ - sandbox, - }) { + async function test_AboutPocketParent_receiveMessage_PKT_show_saved() { await aboutPocketParent.receiveMessage({ name: "PKT_show_saved", }); @@ -113,9 +109,7 @@ test_runner( } ); -test_runner(async function test_AboutPocketParent_receiveMessage_PKT_close({ - sandbox, -}) { +test_runner(async function test_AboutPocketParent_receiveMessage_PKT_close() { await aboutPocketParent.receiveMessage({ name: "PKT_close", }); @@ -130,9 +124,7 @@ test_runner(async function test_AboutPocketParent_receiveMessage_PKT_close({ }); test_runner( - async function test_AboutPocketParent_receiveMessage_PKT_openTabWithUrl({ - sandbox, - }) { + async function test_AboutPocketParent_receiveMessage_PKT_openTabWithUrl() { await aboutPocketParent.receiveMessage({ name: "PKT_openTabWithUrl", data: { foo: 1 }, @@ -155,9 +147,7 @@ test_runner( ); test_runner( - async function test_AboutPocketParent_receiveMessage_PKT_openTabWithPocketUrl({ - sandbox, - }) { + async function test_AboutPocketParent_receiveMessage_PKT_openTabWithPocketUrl() { await aboutPocketParent.receiveMessage({ name: "PKT_openTabWithPocketUrl", data: { foo: 1 }, diff --git a/browser/components/preferences/preferences.js b/browser/components/preferences/preferences.js index c30a51c67c..0cd498fd96 100644 --- a/browser/components/preferences/preferences.js +++ b/browser/components/preferences/preferences.js @@ -276,22 +276,6 @@ function init_all() { }); } -function telemetryBucketForCategory(category) { - category = category.toLowerCase(); - switch (category) { - case "containers": - case "general": - case "home": - case "privacy": - case "search": - case "sync": - case "searchresults": - return category; - default: - return "unknown"; - } -} - function onHashChange() { gotoPref(null, "hash"); } @@ -454,16 +438,6 @@ function search(aQuery, aAttribute) { } element.classList.remove("visually-hidden"); } - - let keysets = mainPrefPane.getElementsByTagName("keyset"); - for (let element of keysets) { - let attributeValue = element.getAttribute(aAttribute); - if (attributeValue == aQuery) { - element.removeAttribute("disabled"); - } else { - element.setAttribute("disabled", true); - } - } } async function spotlight(subcategory, category) { diff --git a/browser/components/preferences/preferences.xhtml b/browser/components/preferences/preferences.xhtml index eee227822a..64062f1f77 100644 --- a/browser/components/preferences/preferences.xhtml +++ b/browser/components/preferences/preferences.xhtml @@ -85,6 +85,8 @@ <script type="module" src="chrome://global/content/elements/moz-toggle.mjs"/> <script type="module" src="chrome://global/content/elements/moz-message-bar.mjs" /> <script type="module" src="chrome://global/content/elements/moz-label.mjs"/> + <script type="module" src="chrome://global/content/elements/moz-card.mjs"></script> + <script type="module" src="chrome://global/content/elements/moz-button.mjs"></script> </head> <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" @@ -224,7 +226,7 @@ <hbox class="sticky-inner-container" pack="end" align="start"> <hbox id="policies-container" class="info-box-container smaller-font-size" flex="1" hidden="true"> <hbox class="info-icon-container"> - <html:img class="info-icon"></html:img> + <html:img class="info-icon" data-l10n-attrs="alt" data-l10n-id="managed-notice-info-icon"></html:img> </hbox> <hbox align="center" flex="1"> <html:a href="about:policies" target="_blank" data-l10n-id="managed-notice"/> diff --git a/browser/components/preferences/privacy.inc.xhtml b/browser/components/preferences/privacy.inc.xhtml index 224a5f5cbb..5c45771bc2 100644 --- a/browser/components/preferences/privacy.inc.xhtml +++ b/browser/components/preferences/privacy.inc.xhtml @@ -1182,21 +1182,18 @@ <label class="doh-status-label" id="dohResolver"/> <label class="doh-status-label" id="dohSteeringStatus" data-l10n-id="preferences-doh-steering-status" hidden="true"/> </vbox> - <hbox id="dohExceptionBox"> - <label flex="1" data-l10n-id="preferences-doh-exceptions-description"/> - <button id="dohExceptionsButton" - is="highlightable-button" - class="accessory-button" - data-l10n-id="preferences-doh-manage-exceptions" - search-l10n-ids=" - permissions-doh-entry-field, - permissions-doh-add-exception.label, - permissions-doh-remove.label, - permissions-doh-remove-all.label, - permissions-exceptions-doh-window.title, - permissions-exceptions-manage-doh-desc, - "/> - </hbox> + <button id="dohExceptionsButton" + is="highlightable-button" + class="accessory-button" + data-l10n-id="preferences-doh-manage-exceptions" + search-l10n-ids=" + permissions-doh-entry-field, + permissions-doh-add-exception.label, + permissions-doh-remove.label, + permissions-doh-remove-all.label, + permissions-exceptions-doh-window.title, + permissions-exceptions-manage-doh-desc, + "/> <vbox> <label><html:h2 id="dohGroupMessage" data-l10n-id="preferences-doh-group-message2"/></label> <vbox id="dohCategories"> diff --git a/browser/components/preferences/privacy.js b/browser/components/preferences/privacy.js index 3b07b9cabf..89fed04e21 100644 --- a/browser/components/preferences/privacy.js +++ b/browser/components/preferences/privacy.js @@ -60,6 +60,13 @@ ChromeUtils.defineLazyGetter(this, "AlertsServiceDND", function () { } }); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gParentalControlsService", + "@mozilla.org/parental-controls-service;1", + "nsIParentalControlsService" +); + XPCOMUtils.defineLazyPreferenceGetter( this, "OS_AUTH_ENABLED", @@ -682,11 +689,14 @@ var gPrivacyPane = { function computeStatus() { let mode = Services.dns.currentTrrMode; - let confirmationState = Services.dns.currentTrrConfirmationState; if ( mode == Ci.nsIDNSService.MODE_TRRFIRST || mode == Ci.nsIDNSService.MODE_TRRONLY ) { + if (lazy.gParentalControlsService.parentalControlsEnabled) { + return "preferences-doh-status-not-active"; + } + let confirmationState = Services.dns.currentTrrConfirmationState; switch (confirmationState) { case Ci.nsIDNSService.CONFIRM_TRYING_OK: case Ci.nsIDNSService.CONFIRM_OK: @@ -702,7 +712,16 @@ var gPrivacyPane = { let errReason = ""; let confirmationStatus = Services.dns.lastConfirmationStatus; - if (confirmationStatus != Cr.NS_OK) { + let mode = Services.dns.currentTrrMode; + if ( + (mode == Ci.nsIDNSService.MODE_TRRFIRST || + mode == Ci.nsIDNSService.MODE_TRRONLY) && + lazy.gParentalControlsService.parentalControlsEnabled + ) { + errReason = Services.dns.getTRRSkipReasonName( + Ci.nsITRRSkipReason.TRR_PARENTAL_CONTROL + ); + } else if (confirmationStatus != Cr.NS_OK) { errReason = ChromeUtils.getXPCOMErrorName(confirmationStatus); } else { errReason = Services.dns.getTRRSkipReasonName( diff --git a/browser/components/preferences/sync.inc.xhtml b/browser/components/preferences/sync.inc.xhtml index d3af690b93..492491a369 100644 --- a/browser/components/preferences/sync.inc.xhtml +++ b/browser/components/preferences/sync.inc.xhtml @@ -22,7 +22,7 @@ <description id="noFxaDescription" class="description-deemphasized" flex="1" data-l10n-id="sync-signedout-description2"/> </vbox> <vbox> - <image class="fxaSyncIllustration"/> + <image class="fxaSyncIllustration" alt=""/> </vbox> </hbox> <hbox id="fxaNoLoginStatus" align="center" flex="1"> @@ -37,6 +37,7 @@ </hbox> <label class="fxaMobilePromo" data-l10n-id="sync-mobile-promo"> <html:img + role="none" src="chrome://browser/skin/logo-android.svg" data-l10n-name="android-icon" class="androidIcon"/> @@ -44,6 +45,7 @@ data-l10n-name="android-link" class="fxaMobilePromo-android text-link" target="_blank"/> <html:img + role="none" src="chrome://browser/skin/logo-ios.svg" data-l10n-name="ios-icon" class="iOSIcon"/> @@ -66,7 +68,8 @@ <image id="openChangeProfileImage" class="fxaProfileImage actionable" role="button" - data-l10n-id="sync-profile-picture"/> + data-l10n-attrs="alt" + data-l10n-id="sync-profile-picture-with-alt"/> <vbox flex="1" pack="center"> <hbox flex="1" align="baseline"> <label id="fxaDisplayName" hidden="true"> @@ -88,11 +91,15 @@ <!-- logged in to an unverified account --> <hbox id="fxaLoginUnverified"> <vbox> - <image class="fxaProfileImage"/> + <image class="fxaProfileImage" + data-l10n-attrs="alt" + data-l10n-id="sync-profile-picture-account-problem"/> </vbox> <vbox flex="1" pack="center"> <hbox align="center"> - <image class="fxaLoginRejectedWarning"/> + <image class="fxaLoginRejectedWarning" + data-l10n-attrs="alt" + data-l10n-id="fxa-login-rejected-warning"/> <description flex="1" class="l10nArgsEmailAddress" data-l10n-id="sync-signedin-unverified" @@ -112,11 +119,15 @@ <!-- logged in locally but server rejected credentials --> <hbox id="fxaLoginRejected"> <vbox> - <image class="fxaProfileImage"/> + <image class="fxaProfileImage" + data-l10n-attrs="alt" + data-l10n-id="sync-profile-picture-account-problem"/> </vbox> <vbox flex="1" pack="center"> <hbox align="center"> - <image class="fxaLoginRejectedWarning"/> + <image class="fxaLoginRejectedWarning" + data-l10n-attrs="alt" + data-l10n-id="fxa-login-rejected-warning"/> <description flex="1" class="l10nArgsEmailAddress" data-l10n-id="sync-signedin-login-failure" @@ -187,35 +198,35 @@ <label data-l10n-id="sync-syncing-across-devices-heading"/> <html:div class="sync-engines-list"> <html:div engine_preference="services.sync.engine.bookmarks"> - <image class="sync-engine-image sync-engine-bookmarks"/> + <image class="sync-engine-image sync-engine-bookmarks" alt=""/> <label data-l10n-id="sync-currently-syncing-bookmarks"/> </html:div> <html:div engine_preference="services.sync.engine.history"> - <image class="sync-engine-image sync-engine-history"/> + <image class="sync-engine-image sync-engine-history" alt=""/> <label data-l10n-id="sync-currently-syncing-history"/> </html:div> <html:div engine_preference="services.sync.engine.tabs"> - <image class="sync-engine-image sync-engine-tabs"/> + <image class="sync-engine-image sync-engine-tabs" alt=""/> <label data-l10n-id="sync-currently-syncing-tabs"/> </html:div> <html:div engine_preference="services.sync.engine.passwords"> - <image class="sync-engine-image sync-engine-passwords"/> + <image class="sync-engine-image sync-engine-passwords" alt=""/> <label data-l10n-id="sync-currently-syncing-passwords"/> </html:div> <html:div engine_preference="services.sync.engine.addresses"> - <image class="sync-engine-image sync-engine-addresses"/> + <image class="sync-engine-image sync-engine-addresses" alt=""/> <label data-l10n-id="sync-currently-syncing-addresses"/> </html:div> <html:div engine_preference="services.sync.engine.creditcards"> - <image class="sync-engine-image sync-engine-creditcards"/> + <image class="sync-engine-image sync-engine-creditcards" alt=""/> <label data-l10n-id="sync-currently-syncing-payment-methods"/> </html:div> <html:div engine_preference="services.sync.engine.addons"> - <image class="sync-engine-image sync-engine-addons"/> + <image class="sync-engine-image sync-engine-addons" alt=""/> <label data-l10n-id="sync-currently-syncing-addons"/> </html:div> <html:div engine_preference="services.sync.engine.prefs"> - <image class="sync-engine-image sync-engine-prefs"/> + <image class="sync-engine-image sync-engine-prefs" alt=""/> <label data-l10n-id="sync-currently-syncing-settings"/> </html:div> </html:div> diff --git a/browser/components/preferences/tests/browser.toml b/browser/components/preferences/tests/browser.toml index 9e619ce4be..523110dbc9 100644 --- a/browser/components/preferences/tests/browser.toml +++ b/browser/components/preferences/tests/browser.toml @@ -10,6 +10,11 @@ support-files = [ "addons/set_homepage.xpi", "addons/set_newtab.xpi", ] +skip-if = [ + "os == 'linux' && os_version == '18.04' && asan", # manifest runs too long + "os == 'linux' && os_version == '18.04' && tsan", # manifest runs too long + "win11_2009 && asan", # manifest runs too long +] ["browser_about_settings.js"] @@ -276,7 +281,6 @@ support-files = [ "subdialog.xhtml", "subdialog2.xhtml", ] -fail-if = ["a11y_checks"] # Bug 1854636 clicked label.dialogTitle, vbox#dialogTemplate.dialogOverlay may not be focusable ["browser_sync_chooseWhatToSync.js"] diff --git a/browser/components/preferences/tests/browser_applications_selection.js b/browser/components/preferences/tests/browser_applications_selection.js index 683ce76a89..23f0e00af8 100644 --- a/browser/components/preferences/tests/browser_applications_selection.js +++ b/browser/components/preferences/tests/browser_applications_selection.js @@ -335,10 +335,12 @@ add_task(async function sortingCheck() { "Number of items should not change." ); for (let i = 0; i < siteItems.length - 1; ++i) { - let aType = siteItems[i].getAttribute("actionDescription").toLowerCase(); - let bType = siteItems[i + 1] - .getAttribute("actionDescription") - .toLowerCase(); + let aType = ( + siteItems[i].getAttribute("actionDescription") || "" + ).toLowerCase(); + let bType = ( + siteItems[i + 1].getAttribute("actionDescription") || "" + ).toLowerCase(); let result = 0; if (aType > bType) { result = 1; @@ -375,10 +377,12 @@ add_task(async function sortingCheck() { "Number of items should not change." ); for (let i = 0; i < siteItems.length - 1; ++i) { - let aType = siteItems[i].getAttribute("typeDescription").toLowerCase(); - let bType = siteItems[i + 1] - .getAttribute("typeDescription") - .toLowerCase(); + let aType = ( + siteItems[i].getAttribute("typeDescription") || "" + ).toLowerCase(); + let bType = ( + siteItems[i + 1].getAttribute("typeDescription") || "" + ).toLowerCase(); let result = 0; if (aType > bType) { result = 1; diff --git a/browser/components/preferences/tests/browser_contentblocking.js b/browser/components/preferences/tests/browser_contentblocking.js index 3d33f2ed7d..c178233a72 100644 --- a/browser/components/preferences/tests/browser_contentblocking.js +++ b/browser/components/preferences/tests/browser_contentblocking.js @@ -1021,7 +1021,7 @@ add_task(async function testDisableTPCheckBoxDisablesEmailTP() { // Verify the checkbox is unchecked after clicking. is( tpCheckbox.getAttribute("checked"), - "", + null, "Tracking protection checkbox is unchecked" ); diff --git a/browser/components/preferences/tests/browser_privacy_dnsoverhttps.js b/browser/components/preferences/tests/browser_privacy_dnsoverhttps.js index 48469cfce4..ebe9c41127 100644 --- a/browser/components/preferences/tests/browser_privacy_dnsoverhttps.js +++ b/browser/components/preferences/tests/browser_privacy_dnsoverhttps.js @@ -16,6 +16,10 @@ ChromeUtils.defineESModuleGetters(this, { DoHTestUtils: "resource://testing-common/DoHTestUtils.sys.mjs", }); +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + const TRR_MODE_PREF = "network.trr.mode"; const TRR_URI_PREF = "network.trr.uri"; const TRR_CUSTOM_URI_PREF = "network.trr.custom_uri"; @@ -106,6 +110,164 @@ function waitForPrefObserver(name) { }); } +// Mock parental controls service in order to enable it +let parentalControlsService = { + parentalControlsEnabled: true, + QueryInterface: ChromeUtils.generateQI(["nsIParentalControlsService"]), +}; +let mockParentalControlsServiceCid = undefined; + +async function setMockParentalControlEnabled(aEnabled) { + if (mockParentalControlsServiceCid != undefined) { + MockRegistrar.unregister(mockParentalControlsServiceCid); + mockParentalControlsServiceCid = undefined; + } + if (aEnabled) { + mockParentalControlsServiceCid = MockRegistrar.register( + "@mozilla.org/parental-controls-service;1", + parentalControlsService + ); + } + Services.dns.reloadParentalControlEnabled(); +} + +add_task(async function testParentalControls() { + async function withConfiguration(configuration, fn) { + info("testParentalControls"); + + await resetPrefs(); + Services.prefs.setIntPref(TRR_MODE_PREF, configuration.trr_mode); + await setMockParentalControlEnabled(configuration.parentalControlsState); + + await openPreferencesViaOpenPreferencesAPI("privacy", { leaveOpen: true }); + let doc = gBrowser.selectedBrowser.contentDocument; + let statusElement = doc.getElementById("dohStatus"); + + await TestUtils.waitForCondition(() => { + return ( + document.l10n.getAttributes(statusElement).args.status == + configuration.wait_for_doh_status + ); + }); + + await fn({ + statusElement, + }); + + gBrowser.removeCurrentTab(); + await setMockParentalControlEnabled(false); + } + + info("Check parental controls disabled, TRR off"); + await withConfiguration( + { + parentalControlsState: false, + trr_mode: 0, + wait_for_doh_status: "Off", + }, + async res => { + is( + document.l10n.getAttributes(res.statusElement).args.status, + "Off", + "expecting status off" + ); + } + ); + + info("Check parental controls enabled, TRR off"); + await withConfiguration( + { + parentalControlsState: true, + trr_mode: 0, + wait_for_doh_status: "Off", + }, + async res => { + is( + document.l10n.getAttributes(res.statusElement).args.status, + "Off", + "expecting status off" + ); + } + ); + + // Enable the rollout. + await DoHTestUtils.loadRemoteSettingsConfig({ + providers: "example", + rolloutEnabled: true, + steeringEnabled: false, + steeringProviders: "", + autoDefaultEnabled: false, + autoDefaultProviders: "", + id: "global", + }); + + info("Check parental controls disabled, TRR first"); + await withConfiguration( + { + parentalControlsState: false, + trr_mode: 2, + wait_for_doh_status: "Active", + }, + async res => { + is( + document.l10n.getAttributes(res.statusElement).args.status, + "Active", + "expecting status active" + ); + } + ); + + info("Check parental controls enabled, TRR first"); + await withConfiguration( + { + parentalControlsState: true, + trr_mode: 2, + wait_for_doh_status: "Not active (TRR_PARENTAL_CONTROL)", + }, + async res => { + is( + document.l10n.getAttributes(res.statusElement).args.status, + "Not active (TRR_PARENTAL_CONTROL)", + "expecting status not active" + ); + } + ); + + info("Check parental controls disabled, TRR only"); + await withConfiguration( + { + parentalControlsState: false, + trr_mode: 3, + wait_for_doh_status: "Active", + }, + async res => { + is( + document.l10n.getAttributes(res.statusElement).args.status, + "Active", + "expecting status active" + ); + } + ); + + info("Check parental controls enabled, TRR only"); + await withConfiguration( + { + parentalControlsState: true, + trr_mode: 3, + wait_for_doh_status: "Not active (TRR_PARENTAL_CONTROL)", + }, + async res => { + is( + document.l10n.getAttributes(res.statusElement).args.status, + "Not active (TRR_PARENTAL_CONTROL)", + "expecting status not active" + ); + } + ); + + await resetPrefs(); +}); + async function testWithProperties(props, startTime) { info( Date.now() - diff --git a/browser/components/preferences/tests/browser_subdialogs.js b/browser/components/preferences/tests/browser_subdialogs.js index 8763ae9146..b604ac0a7f 100644 --- a/browser/components/preferences/tests/browser_subdialogs.js +++ b/browser/components/preferences/tests/browser_subdialogs.js @@ -173,7 +173,7 @@ async function close_subdialog_and_test_generic_end_state( ); Assert.equal( frame.getAttribute("style"), - "", + null, "inline styles should be cleared" ); Assert.equal( @@ -407,17 +407,29 @@ add_task(async function background_click_should_close_dialog() { // Clicking on an inactive part of dialog itself should not close the dialog. // Click the dialog title bar here to make sure nothing happens. info("clicking the dialog title bar"); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to confirm the opened + // dialog won't be dismissed. It is not meant to be interactive and is not + // expected to be accessible, therefore this rule check shall be ignored by + // a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); BrowserTestUtils.synthesizeMouseAtCenter( ".dialogTitle", {}, tab.linkedBrowser ); + AccessibilityUtils.resetEnv(); // Close the dialog by clicking on the overlay background. Simulate a click // at point (2,2) instead of (0,0) so we are sure we're clicking on the // overlay background instead of some boundary condition that a real user // would never click. info("clicking the overlay background"); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to dismiss the opened + // dialog with a mouse which can be done by assistive technology and keyboard + // by pressing `Esc` key, this rule check shall be ignored by a11y_checks. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); await close_subdialog_and_test_generic_end_state( tab.linkedBrowser, function () { @@ -432,6 +444,7 @@ add_task(async function background_click_should_close_dialog() { 0, { runClosingFnOutsideOfContentTask: true } ); + AccessibilityUtils.resetEnv(); }); add_task(async function escape_should_close_dialog() { diff --git a/browser/components/preferences/tests/siteData/browser.toml b/browser/components/preferences/tests/siteData/browser.toml index 9f4f8306e1..b7e6ba1b6d 100644 --- a/browser/components/preferences/tests/siteData/browser.toml +++ b/browser/components/preferences/tests/siteData/browser.toml @@ -10,6 +10,8 @@ support-files = [ ["browser_clearSiteData.js"] +["browser_clearSiteData_v2.js"] + ["browser_siteData.js"] ["browser_siteData2.js"] diff --git a/browser/components/preferences/tests/siteData/browser_clearSiteData.js b/browser/components/preferences/tests/siteData/browser_clearSiteData.js index 7ae1fda453..4924dccfea 100644 --- a/browser/components/preferences/tests/siteData/browser_clearSiteData.js +++ b/browser/components/preferences/tests/siteData/browser_clearSiteData.js @@ -7,10 +7,6 @@ const { PermissionTestUtils } = ChromeUtils.importESModule( "resource://testing-common/PermissionTestUtils.sys.mjs" ); -let useOldClearHistoryDialog = Services.prefs.getBoolPref( - "privacy.sanitize.useOldClearHistoryDialog" -); - async function testClearData(clearSiteData, clearCache) { PermissionTestUtils.add( TEST_QUOTA_USAGE_ORIGIN, @@ -64,9 +60,7 @@ async function testClearData(clearSiteData, clearCache) { let doc = gBrowser.selectedBrowser.contentDocument; let clearSiteDataButton = doc.getElementById("clearSiteDataButton"); - let url = useOldClearHistoryDialog - ? "chrome://browser/content/preferences/dialogs/clearSiteData.xhtml" - : "chrome://browser/content/sanitize_v2.xhtml"; + let url = "chrome://browser/content/preferences/dialogs/clearSiteData.xhtml"; let dialogOpened = promiseLoadSubDialog(url); clearSiteDataButton.doCommand(); let dialogWin = await dialogOpened; @@ -78,10 +72,8 @@ async function testClearData(clearSiteData, clearCache) { // since we've had cache intermittently changing under our feet. let [, convertedCacheUnit] = DownloadUtils.convertByteUnits(cacheUsage); - let cookiesCheckboxId = useOldClearHistoryDialog - ? "clearSiteData" - : "cookiesAndStorage"; - let cacheCheckboxId = useOldClearHistoryDialog ? "clearCache" : "cache"; + let cookiesCheckboxId = "clearSiteData"; + let cacheCheckboxId = "clearCache"; let clearSiteDataCheckbox = dialogWin.document.getElementById(cookiesCheckboxId); let clearCacheCheckbox = dialogWin.document.getElementById(cacheCheckboxId); @@ -106,28 +98,13 @@ async function testClearData(clearSiteData, clearCache) { clearSiteDataCheckbox.checked = clearSiteData; clearCacheCheckbox.checked = clearCache; - if (!useOldClearHistoryDialog) { - // The new clear history dialog has a seperate checkbox for site settings - let siteSettingsCheckbox = - dialogWin.document.getElementById("siteSettings"); - siteSettingsCheckbox.checked = clearSiteData; - // select clear everything to match the old dialog boxes behaviour for this test - let timespanSelection = dialogWin.document.getElementById( - "sanitizeDurationChoice" - ); - timespanSelection.value = 0; - } // Some additional promises/assertions to wait for // when deleting site data. let acceptPromise; let updatePromise; let cookiesClearedPromise; if (clearSiteData) { - // the new clear history dialog does not have a extra prompt - // to clear site data after clicking clear - if (useOldClearHistoryDialog) { - acceptPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); - } + acceptPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); updatePromise = promiseSiteDataManagerSitesUpdated(); cookiesClearedPromise = promiseCookiesCleared(); } @@ -137,7 +114,7 @@ async function testClearData(clearSiteData, clearCache) { let clearButton = dialogWin.document .querySelector("dialog") .getButton("accept"); - if (!clearSiteData && !clearCache && useOldClearHistoryDialog) { + if (!clearSiteData && !clearCache) { // Simulate user input on one of the checkboxes to trigger the event listener for // disabling the clearButton. clearCacheCheckbox.doCommand(); @@ -158,7 +135,7 @@ async function testClearData(clearSiteData, clearCache) { // For site data we display an extra warning dialog, make sure // to accept it. - if (clearSiteData && useOldClearHistoryDialog) { + if (clearSiteData) { await acceptPromise; } @@ -222,6 +199,12 @@ async function testClearData(clearSiteData, clearCache) { await SiteDataManager.removeAll(); } +add_setup(function () { + SpecialPowers.pushPrefEnv({ + set: [["privacy.sanitize.useOldClearHistoryDialog", true]], + }); +}); + // Test opening the "Clear All Data" dialog and cancelling. add_task(async function () { await testClearData(false, false); diff --git a/browser/components/preferences/tests/siteData/browser_clearSiteData_v2.js b/browser/components/preferences/tests/siteData/browser_clearSiteData_v2.js new file mode 100644 index 0000000000..8cb8be25b3 --- /dev/null +++ b/browser/components/preferences/tests/siteData/browser_clearSiteData_v2.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +async function testClearData(clearSiteData, clearCache) { + PermissionTestUtils.add( + TEST_QUOTA_USAGE_ORIGIN, + "persistent-storage", + Services.perms.ALLOW_ACTION + ); + + // Open a test site which saves into appcache. + await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_OFFLINE_URL); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + // Fill indexedDB with test data. + // Don't wait for the page to load, to register the content event handler as quickly as possible. + // If this test goes intermittent, we might have to tell the page to wait longer before + // firing the event. + BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_QUOTA_USAGE_URL, false); + await BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "test-indexedDB-done", + false, + null, + true + ); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + // Register some service workers. + await loadServiceWorkerTestPage(TEST_SERVICE_WORKER_URL); + await promiseServiceWorkerRegisteredFor(TEST_SERVICE_WORKER_URL); + + await openPreferencesViaOpenPreferencesAPI("privacy", { leaveOpen: true }); + + // Test the initial states. + let cacheUsage = await SiteDataManager.getCacheSize(); + let quotaUsage = await SiteDataTestUtils.getQuotaUsage( + TEST_QUOTA_USAGE_ORIGIN + ); + let totalUsage = await SiteDataManager.getTotalUsage(); + Assert.greater(cacheUsage, 0, "The cache usage should not be 0"); + Assert.greater(quotaUsage, 0, "The quota usage should not be 0"); + Assert.greater(totalUsage, 0, "The total usage should not be 0"); + + let initialSizeLabelValue = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + let sizeLabel = content.document.getElementById("totalSiteDataSize"); + return sizeLabel.textContent; + } + ); + + let doc = gBrowser.selectedBrowser.contentDocument; + let clearSiteDataButton = doc.getElementById("clearSiteDataButton"); + + let url = "chrome://browser/content/sanitize_v2.xhtml"; + let dialogOpened = promiseLoadSubDialog(url); + clearSiteDataButton.doCommand(); + let dialogWin = await dialogOpened; + + // Convert the usage numbers in the same way the UI does it to assert + // that they're displayed in the dialog. + let [convertedTotalUsage] = DownloadUtils.convertByteUnits(totalUsage); + // For cache we just assert that the right unit (KB, probably) is displayed, + // since we've had cache intermittently changing under our feet. + let [, convertedCacheUnit] = DownloadUtils.convertByteUnits(cacheUsage); + + let cookiesCheckboxId = "cookiesAndStorage"; + let cacheCheckboxId = "cache"; + let clearSiteDataCheckbox = + dialogWin.document.getElementById(cookiesCheckboxId); + let clearCacheCheckbox = dialogWin.document.getElementById(cacheCheckboxId); + // The usage details are filled asynchronously, so we assert that they're present by + // waiting for them to be filled in. + await Promise.all([ + TestUtils.waitForCondition( + () => + clearSiteDataCheckbox.label && + clearSiteDataCheckbox.label.includes(convertedTotalUsage), + "Should show the quota usage" + ), + TestUtils.waitForCondition( + () => + clearCacheCheckbox.label && + clearCacheCheckbox.label.includes(convertedCacheUnit), + "Should show the cache usage" + ), + ]); + + // Check the boxes according to our test input. + clearSiteDataCheckbox.checked = clearSiteData; + clearCacheCheckbox.checked = clearCache; + + // select clear everything to match the old dialog boxes behaviour for this test + let timespanSelection = dialogWin.document.getElementById( + "sanitizeDurationChoice" + ); + timespanSelection.value = 1; + + // Some additional promises/assertions to wait for + // when deleting site data. + let updatePromise; + if (clearSiteData) { + // the new clear history dialog does not have a extra prompt + // to clear site data after clicking clear + updatePromise = promiseSiteDataManagerSitesUpdated(); + } + + let dialogClosed = BrowserTestUtils.waitForEvent(dialogWin, "unload"); + + let clearButton = dialogWin.document + .querySelector("dialog") + .getButton("accept"); + let cancelButton = dialogWin.document + .querySelector("dialog") + .getButton("cancel"); + + if (!clearSiteData && !clearCache) { + // Cancel, since we can't delete anything. + cancelButton.click(); + } else { + // Delete stuff! + clearButton.click(); + } + + await dialogClosed; + + if (clearCache) { + TestUtils.waitForCondition(async function () { + let usage = await SiteDataManager.getCacheSize(); + return usage == 0; + }, "The cache usage should be removed"); + } else { + Assert.greater( + await SiteDataManager.getCacheSize(), + 0, + "The cache usage should not be 0" + ); + } + + if (clearSiteData) { + await updatePromise; + await promiseServiceWorkersCleared(); + + TestUtils.waitForCondition(async function () { + let usage = await SiteDataManager.getTotalUsage(); + return usage == 0; + }, "The total usage should be removed"); + } else { + quotaUsage = await SiteDataTestUtils.getQuotaUsage(TEST_QUOTA_USAGE_ORIGIN); + totalUsage = await SiteDataManager.getTotalUsage(); + Assert.greater(quotaUsage, 0, "The quota usage should not be 0"); + Assert.greater(totalUsage, 0, "The total usage should not be 0"); + } + + if (clearCache || clearSiteData) { + // Check that the size label in about:preferences updates after we cleared data. + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [{ initialSizeLabelValue }], + async function (opts) { + let sizeLabel = content.document.getElementById("totalSiteDataSize"); + await ContentTaskUtils.waitForCondition( + () => sizeLabel.textContent != opts.initialSizeLabelValue, + "Site data size label should have updated." + ); + } + ); + } + + let permission = PermissionTestUtils.getPermissionObject( + TEST_QUOTA_USAGE_ORIGIN, + "persistent-storage" + ); + is( + clearSiteData ? permission : permission.capability, + clearSiteData ? null : Services.perms.ALLOW_ACTION, + "Should have the correct permission state." + ); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await SiteDataManager.removeAll(); +} + +add_setup(function () { + SpecialPowers.pushPrefEnv({ + set: [["privacy.sanitize.useOldClearHistoryDialog", false]], + }); + + // The tests in this file all test specific interactions with the new clear + // history dialog and can't be split up. + requestLongerTimeout(2); +}); + +// Test opening the "Clear All Data" dialog and cancelling. +add_task(async function testNoSiteDataNoCacheClearing() { + await testClearData(false, false); +}); + +// Test opening the "Clear All Data" dialog and removing all site data. +add_task(async function testSiteDataClearing() { + await testClearData(true, false); +}); + +// Test opening the "Clear All Data" dialog and removing all cache. +add_task(async function testCacheClearing() { + await testClearData(false, true); +}); + +// Test opening the "Clear All Data" dialog and removing everything. +add_task(async function testSiteDataAndCacheClearing() { + await testClearData(true, true); +}); + +// Test clearing persistent storage +add_task(async function testPersistentStorage() { + PermissionTestUtils.add( + TEST_QUOTA_USAGE_ORIGIN, + "persistent-storage", + Services.perms.ALLOW_ACTION + ); + + await openPreferencesViaOpenPreferencesAPI("privacy", { leaveOpen: true }); + + let doc = gBrowser.selectedBrowser.contentDocument; + let clearSiteDataButton = doc.getElementById("clearSiteDataButton"); + + let url = "chrome://browser/content/sanitize_v2.xhtml"; + let dialogOpened = promiseLoadSubDialog(url); + clearSiteDataButton.doCommand(); + let dialogWin = await dialogOpened; + let dialogClosed = BrowserTestUtils.waitForEvent(dialogWin, "unload"); + + let timespanSelection = dialogWin.document.getElementById( + "sanitizeDurationChoice" + ); + timespanSelection.value = 1; + let clearButton = dialogWin.document + .querySelector("dialog") + .getButton("accept"); + clearButton.click(); + await dialogClosed; + + let permission = PermissionTestUtils.getPermissionObject( + TEST_QUOTA_USAGE_ORIGIN, + "persistent-storage" + ); + is(permission, null, "Should have the correct permission state."); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/preferences/translations.inc.xhtml b/browser/components/preferences/translations.inc.xhtml index 9463fde707..1143cbc6d0 100644 --- a/browser/components/preferences/translations.inc.xhtml +++ b/browser/components/preferences/translations.inc.xhtml @@ -19,45 +19,63 @@ <p id="translations-settings-description" data-l10n-id="translations-settings-description"/> - <div class="translations-settings-manage-list" - id="translations-settings-manage-always-translate-list"> + <moz-card class="translations-settings-manage-section" data-l10n-attrs="heading" + id="translations-settings-always-translate-section"> <div class="translations-settings-manage-language"> <h2 id="translations-settings-always-translate" data-l10n-id="translations-settings-always-translate"/> <xul:menulist id="translations-settings-always-translate-list" - data-l10n-id="translations-settings-add-language-button"> - <xul:menupopup/> + data-l10n-id="translations-settings-add-language-button" + aria-labelledby="translations-settings-always-translate"> + <!-- The list of <menuitem> will be dynamically inserted. --> + <xul:menupopup id="translations-settings-always-translate-popup"/> </xul:menulist> </div> - </div> + </moz-card> - <div id="translations-settings-manage-never-translate-list" - class="translations-settings-manage-list"> + <moz-card id="translations-settings-never-translate-section" + class="translations-settings-manage-section"> <div class="translations-settings-manage-language"> <h2 id="translations-settings-never-translate" data-l10n-id="translations-settings-never-translate"/> <xul:menulist id="translations-settings-never-translate-list" - data-l10n-id="translations-settings-add-language-button"> - <xul:menupopup/> + data-l10n-id="translations-settings-add-language-button" + aria-labelledby="translations-settings-never-translate"> + <!-- The list of <menuitem> will be dynamically inserted. --> + <xul:menupopup id="translations-settings-never-translate-popup"/> </xul:menulist> </div> - </div> + </moz-card> - <div id="translations-settings-never-sites-list" class="translations-settings-manage-list" > - <div class="translations-settings-manage-list-info" > + <moz-card id="translations-settings-never-sites-section" + class="translations-settings-manage-section"> + <div class="translations-settings-manage-section-info" > <h2 id="translations-settings-never-sites-header" data-l10n-id="translations-settings-never-sites-header"/> <p id="translations-settings-never-sites" data-l10n-id="translations-settings-never-sites-description"/> </div> - </div> + </moz-card> - <div id="translations-settings-manage-install-list" class="translations-settings-manage-list"> - <div class="translations-settings-manage-list-info"> - <h2 id="translations-settings-download-languages" - data-l10n-id="translations-settings-download-languages"/> + <moz-card id="translations-settings-download-section" + class="translations-settings-manage-section"> + <div class="translations-settings-manage-section-info"> + <h2 data-l10n-id="translations-settings-download-languages"/> <a is="moz-support-link" class="learnMore" id="download-languages-learn-more" data-l10n-id="translations-settings-download-languages-link" support-page="website-translation"/> </div> - </div> + <div class="translations-settings-languages-card"> + <h3 class="translations-settings-language-header" data-l10n-id="translations-settings-language-header"></h3> + <div class="translations-settings-language-list"> + <div class="translations-settings-language"> + <moz-button class="translations-settings-download-icon" type="ghost icon" + aria-label="translations-settings-download-all-languages"></moz-button> + <!-- The option to "All languages" is added here. + In translations.js the option to download individual languages is + added dynamically based on the supported language list --> + <label id="translations-settings-download-all-languages" data-l10n-id="translations-settings-download-all-languages"></label> + </div> + </div> + </div> + </moz-card> </div> diff --git a/browser/components/preferences/translations.js b/browser/components/preferences/translations.js index c9cfe472ac..1bfa021d59 100644 --- a/browser/components/preferences/translations.js +++ b/browser/components/preferences/translations.js @@ -4,6 +4,10 @@ /* import-globals-from preferences.js */ +ChromeUtils.defineESModuleGetters(this, { + TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", +}); + let gTranslationsPane = { init() { document @@ -11,5 +15,271 @@ let gTranslationsPane = { .addEventListener("click", function () { gotoPref("general"); }); + + document + .getElementById("translations-settings-always-translate-list") + .addEventListener("command", this.addAlwaysLanguage); + + document + .getElementById("translations-settings-never-translate-list") + .addEventListener("command", this.addNeverLanguage); + + this.buildLanguageDropDowns(); + this.buildDownloadLanguageList(); + }, + + /** + * Populate the Drop down list with the list of supported languages + * for the user to choose languages to add to Always translate and + * Never translate settings list. + */ + async buildLanguageDropDowns() { + const { fromLanguages } = await TranslationsParent.getSupportedLanguages(); + let alwaysLangPopup = document.getElementById( + "translations-settings-always-translate-popup" + ); + let neverLangPopup = document.getElementById( + "translations-settings-never-translate-popup" + ); + + for (const { langTag, displayName } of fromLanguages) { + const alwaysLang = document.createXULElement("menuitem"); + alwaysLang.setAttribute("value", langTag); + alwaysLang.setAttribute("label", displayName); + alwaysLangPopup.appendChild(alwaysLang); + const neverLang = document.createXULElement("menuitem"); + neverLang.setAttribute("value", langTag); + neverLang.setAttribute("label", displayName); + neverLangPopup.appendChild(neverLang); + } + }, + + /** + * Show a list of languages for the user to be able to install + * and uninstall language models for local translation. + */ + async buildDownloadLanguageList() { + const supportedLanguages = await TranslationsParent.getSupportedLanguages(); + const languageList = TranslationsParent.getLanguageList(supportedLanguages); + + let installList = document.querySelector( + ".translations-settings-language-list" + ); + + // The option to download "All languages" is added in xhtml. + // Here the option to download individual languages is dynamically added + // based on the supported language list + installList + .querySelector("moz-button") + .addEventListener("click", installLanguage); + + for (const language of languageList) { + const languageElement = document.createElement("div"); + languageElement.classList.add("translations-settings-language"); + + const languageLabel = document.createElement("label"); + languageLabel.textContent = language.displayName; + languageLabel.setAttribute("value", language.langTag); + // Using the language tag suffix to create unique id for each language + languageLabel.id = "translations-settings-download-" + language.langTag; + + const installButton = document.createElement("moz-button"); + installButton.classList.add("translations-settings-download-icon"); + installButton.setAttribute("type", "ghost icon"); + installButton.addEventListener("click", installLanguage); + installButton.setAttribute("aria-label", languageLabel.id); + + languageElement.appendChild(installButton); + languageElement.appendChild(languageLabel); + installList.appendChild(languageElement); + } + }, + + /** + * Event handler when the user wants to add a language to + * Always translate settings list. + */ + addAlwaysLanguage(event) { + /* TODO: + The function addLanguage adds the HTML element. + It will be moved to the observer in the next Bug - 1881259 . + It is here just to test the UI. + For now a language can be added multiple times. + + In the next bug we will maintain a local state of preferences. + When a language is added or removed, the user event updates the preferences in the Services, + This triggers the observer which compares the preferences in the local state and the + preferences in the Services and adds or removes the language in the local state and updates the + UI to reflect the updated Preferences in the Services. + */ + addLanguage( + event, + "translations-settings-always-translate-section", + deleteAlwaysLanguage + ); + }, + + /** + * Event handler when the user wants to add a language to + * Never translate settings list. + */ + addNeverLanguage(event) { + /* TODO: + The function addLanguage adds the HTML element. + It will be moved to the observer in the next Bug - 1881259 . + It is here just to test the UI. + For now a language can be added multiple times. + + In the next bug we will maintain a local state of preferences. + When a language is added or removed, the user event updates the preferences in the Services, + This triggers the observer which compares the preferences in the local state and the + preferences in the Services and adds or removes the language in the local state and updates the + UI to reflect the updated Preferences in the Services. + */ + addLanguage( + event, + "translations-settings-never-translate-section", + deleteNeverLanguage + ); }, }; + +/** + * Function to add a language selected by the user to the list of + * Always/Never translate settings list. + */ +async function addLanguage(event, listClass, delHandler) { + const translatePrefix = + listClass === "translations-settings-never-translate-section" + ? "never" + : "always"; + let translateSection = document.getElementById(listClass); + let languageList = translateSection.querySelector( + ".translations-settings-language-list" + ); + + // While adding the first language, add the Header and language List div + if (!languageList) { + let languageCard = document.createElement("div"); + languageCard.classList.add("translations-settings-languages-card"); + translateSection.appendChild(languageCard); + + let languageHeader = document.createElement("h3"); + languageCard.appendChild(languageHeader); + languageHeader.setAttribute( + "data-l10n-id", + "translations-settings-language-header" + ); + languageHeader.classList.add("translations-settings-language-header"); + + languageList = document.createElement("div"); + languageList.classList.add("translations-settings-language-list"); + languageCard.appendChild(languageList); + } + const languageElement = document.createElement("div"); + languageElement.classList.add("translations-settings-language"); + // Add the language after the Language Header + languageList.insertBefore(languageElement, languageList.firstChild); + + const languageLabel = document.createElement("label"); + languageLabel.textContent = event.target.getAttribute("label"); + languageLabel.setAttribute("value", event.target.getAttribute("value")); + // Using the language tag suffix to create unique id for each language + // add prefix for the always/never translate + languageLabel.id = + "translations-settings-language-" + + translatePrefix + + "-" + + event.target.getAttribute("value"); + + const delButton = document.createElement("moz-button"); + delButton.classList.add("translations-settings-delete-icon"); + delButton.setAttribute("type", "ghost icon"); + delButton.addEventListener("click", delHandler); + delButton.setAttribute("aria-label", languageLabel.id); + + languageElement.appendChild(delButton); + languageElement.appendChild(languageLabel); + + /* After a language is selected the menulist button display will be set to the + selected langauge. After processing the button event the + data-l10n-id of the menulist button is restored to "Add Language" */ + const menuList = translateSection.querySelector("menulist"); + await document.l10n.translateElements([menuList]); +} + +/** + * Event Handler to delete a language selected by the user from the list of + * Always translate settings list. + */ +function deleteAlwaysLanguage(event) { + /* TODO: + The function removeLanguage removes the HTML element. + It will be moved to the observer in the next Bug - 1881259 . + It is here just to test the UI. + + In the next bug we will maintain a local state of preferences. + When a language is added or removed, the user event updates the preferences in the Services, + This triggers the observer which compares the preferences in the local state and the + preferences in the Services and adds or removes the language in the local state and updates the + UI to reflect the updated Preferences in the Services. + */ + removeLanguage(event); +} + +/** + * Event Handler to delete a language selected by the user from the list of + * Never translate settings list. + */ +function deleteNeverLanguage(event) { + /* TODO: + The function removeLanguage removes the HTML element. + It will be moved to the observer in the next Bug - 1881259 . + It is here just to test the UI. + + In the next bug we will maintain a local state of preferences. + When a language is added or removed, the user event updates the preferences in the Services, + This triggers the observer which compares the preferences in the local state and the + preferences in the Services and adds or removes the language in the local state and updates the + UI to reflect the updated Preferences in the Services. + */ + removeLanguage(event); +} + +/** + * Function to delete a language selected by the user from the list of + * Always/Never translate settings list. + */ +function removeLanguage(event) { + /* Langauge section moz-card -parent of-> Language card -parent of-> + Language heading and Language list -parent of-> + Language Element -parent of-> language button and label + */ + let languageCard = event.target.parentNode.parentNode.parentNode; + event.target.parentNode.remove(); + if (languageCard.children[1].childElementCount === 0) { + // If there is no language in the list remove the + // Language Header and language list div + languageCard.remove(); + } +} + +/** + * Event Handler to install a language model selected by the user + */ +function installLanguage(event) { + event.target.classList.remove("translations-settings-download-icon"); + event.target.classList.add("translations-settings-delete-icon"); + event.target.removeEventListener("click", installLanguage); + event.target.addEventListener("click", unInstallLanguage); +} + +/** + * Event Handler to install a language model selected by the user + */ +function unInstallLanguage(event) { + event.target.classList.remove("translations-settings-delete-icon"); + event.target.classList.add("translations-settings-download-icon"); + event.target.removeEventListener("click", unInstallLanguage); + event.target.addEventListener("click", installLanguage); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js b/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js index a1b9420171..818c412ef6 100644 --- a/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js +++ b/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js @@ -9,7 +9,7 @@ const DUMMY_PAGE = PATH + "empty_file.html"; add_task( async function test_principal_right_click_open_link_in_new_private_win() { - await BrowserTestUtils.withNewTab(TEST_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(TEST_PAGE, async function () { let promiseNewWindow = BrowserTestUtils.waitForNewWindow({ url: DUMMY_PAGE, }); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js index bfe5708a5b..ef207916a5 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js @@ -41,7 +41,7 @@ add_task(async function test_experiment_messaging_system_dismiss() { let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); - await SpecialPowers.spawn(tab1, [LOCALE], async function (locale) { + await SpecialPowers.spawn(tab1, [LOCALE], async function () { content.document.querySelector("#dismiss-btn").click(); info("button clicked"); }); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js index ac42caa2dd..0d4b0a1dbb 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js @@ -47,7 +47,7 @@ add_task(async function test_experiment_messaging_system_impressions() { let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); - await SpecialPowers.spawn(tab1, [LOCALE], async function (locale) { + await SpecialPowers.spawn(tab1, [LOCALE], async function () { is( content.document .querySelector(".promo button") @@ -72,7 +72,7 @@ add_task(async function test_experiment_messaging_system_impressions() { let { win: win2, tab: tab2 } = await openTabAndWaitForRender(); - await SpecialPowers.spawn(tab2, [LOCALE], async function (locale) { + await SpecialPowers.spawn(tab2, [LOCALE], async function () { is( content.document .querySelector(".promo button") diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js index de6aa1f6ba..9d274a28de 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js @@ -46,7 +46,7 @@ function getStorageEntryCount(device, goon) { var visitor = { entryCount: 0, - onCacheStorageInfo(aEntryCount, aConsumption) {}, + onCacheStorageInfo() {}, onCacheEntryInfo(uri) { var urispec = uri.asciiSpec; info(device + ":" + urispec + "\n"); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js index 9b796613a9..acdb4bb30d 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js @@ -31,7 +31,7 @@ function test() { }; function testCheckbox() { win.removeEventListener("load", testCheckbox); - Services.obs.addObserver(function onCertUI(aSubject, aTopic, aData) { + Services.obs.addObserver(function onCertUI() { Services.obs.removeObserver(onCertUI, "cert-exception-ui-ready"); ok(win.gCert, "The certificate information should be available now"); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js index 39e41589b4..ce86cab69c 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js @@ -19,7 +19,7 @@ add_task(async () => { await BrowserTestUtils.browserLoaded(privateTab); let observerExited = { - observe(aSubject, aTopic, aData) { + observe() { ok(false, "Notification received!"); }, }; diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js index eea0ab07ca..46ef974677 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js @@ -32,7 +32,7 @@ function clearAllPlacesFavicons() { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if (aTopic === "places-favicons-expired") { resolve(); Services.obs.removeObserver(observer, "places-favicons-expired"); @@ -59,7 +59,7 @@ function observeFavicon(aIsPrivate, aExpectedCookie, aPageURI) { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { // Make sure that the topic is 'http-on-modify-request'. if (aTopic === "http-on-modify-request") { // We check the privateBrowsingId for the originAttributes of the loading @@ -121,7 +121,7 @@ function observeFavicon(aIsPrivate, aExpectedCookie, aPageURI) { function waitOnFaviconResponse(aFaviconURL) { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { if ( aTopic === "http-on-examine-response" || aTopic === "http-on-examine-cached-response" diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html index 01ed3f3d2c..e7c1920215 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html @@ -5,7 +5,7 @@ </head> <body> <script type="text/javascript"> - navigator.geolocation.getCurrentPosition(function(pos) { + navigator.geolocation.getCurrentPosition(function() { // ignore }); </script> diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js index 1fd28d4ca6..4874e61bd4 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js @@ -6,7 +6,7 @@ add_task(async function test_no_notification_when_pb_autostart() { let observedLastPBContext = false; let observerExited = { - observe(aSubject, aTopic, aData) { + observe() { observedLastPBContext = true; }, }; @@ -31,7 +31,7 @@ add_task(async function test_no_notification_when_pb_autostart() { add_task(async function test_notification_when_about_preferences() { let observedLastPBContext = false; let observerExited = { - observe(aSubject, aTopic, aData) { + observe() { observedLastPBContext = true; }, }; diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js index c46417933a..c57a482752 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js @@ -11,7 +11,7 @@ function test() { let expectedExiting = true; let expectedExited = false; let observerExiting = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { is( aTopic, "last-pb-context-exiting", @@ -26,7 +26,7 @@ function test() { }, }; let observerExited = { - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { is( aTopic, "last-pb-context-exited", diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js index ab74caeb5e..70f6666589 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js @@ -55,7 +55,7 @@ function test() { } function openPrivateBrowsingModeByUI(aWindow, aCallback) { - Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + Services.obs.addObserver(function observer(aSubject) { aSubject.addEventListener( "load", function () { diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js index dd0e2e1b64..ac31b925ed 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js @@ -12,7 +12,7 @@ add_task(async function test() { function promiseLocationChange() { return new Promise(resolve => { - Services.obs.addObserver(function onLocationChange(subj, topic, data) { + Services.obs.addObserver(function onLocationChange(subj, topic) { Services.obs.removeObserver(onLocationChange, topic); resolve(); }, "browser-fullZoom:location-change"); @@ -59,7 +59,7 @@ add_task(async function test() { ); } - function testOnWindow(options, callback) { + function testOnWindow(options) { return BrowserTestUtils.openNewBrowserWindow(options).then(win => { windowsToClose.push(win); windowsToReset.push(win); diff --git a/browser/components/protections/content/protections.mjs b/browser/components/protections/content/protections.mjs index 3204586a2b..412ac54d1b 100644 --- a/browser/components/protections/content/protections.mjs +++ b/browser/components/protections/content/protections.mjs @@ -31,7 +31,7 @@ if (searchParams.has("entrypoint")) { searchParamsChanged = true; } -document.addEventListener("DOMContentLoaded", e => { +document.addEventListener("DOMContentLoaded", () => { if (searchParamsChanged) { let newURL = protocol + pathname; let params = searchParams.toString(); diff --git a/browser/components/protections/test/browser/browser_protections_monitor.js b/browser/components/protections/test/browser/browser_protections_monitor.js index b24d8de55c..e96412edca 100644 --- a/browser/components/protections/test/browser/browser_protections_monitor.js +++ b/browser/components/protections/test/browser/browser_protections_monitor.js @@ -134,7 +134,7 @@ add_task(async function () { await BrowserTestUtils.removeTab(tab); }); -async function checkNoLoginsContentIsDisplayed(tab, expectedLinkContent) { +async function checkNoLoginsContentIsDisplayed(tab) { await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { await ContentTaskUtils.waitForCondition(() => { const noLogins = content.document.querySelector( diff --git a/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs b/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs index 345046ae27..5255bfec46 100644 --- a/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs +++ b/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs @@ -543,7 +543,7 @@ WebProtocolHandlerRegistrar.prototype = { notificationId, { label: { - "l10n-id": "protocolhandler-mailto-handler-set-message", + "l10n-id": "protocolhandler-mailto-handler-set", "l10n-args": { url: aURI.host }, }, priority: osDefaultNotificationBox.PRIORITY_INFO_LOW, @@ -576,7 +576,7 @@ WebProtocolHandlerRegistrar.prototype = { true ); newitem.messageL10nId = - "protocolhandler-mailto-handler-confirm-message"; + "protocolhandler-mailto-handler-confirm"; newitem.removeChild(newitem.buttonContainer); newitem.setAttribute("type", "success"); // from moz-message-bar.css newitem.eventCallback = null; // disable show only once per day for success diff --git a/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs b/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs index ef1f4e1270..836908c7b4 100644 --- a/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs +++ b/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs @@ -594,7 +594,7 @@ export var ReportBrokenSite = new (class ReportBrokenSite { const expectedBrowser = tabbrowser.getBrowserForTab(tab); return new Promise(resolve => { const listener = { - onLocationChange(browser, webProgress, request, uri, flags) { + onLocationChange(browser, webProgress, request, uri) { if ( browser == expectedBrowser && uri.spec == url && diff --git a/browser/components/reportbrokensite/test/browser/browser_back_buttons.js b/browser/components/reportbrokensite/test/browser/browser_back_buttons.js index b8de5f8e95..c004442c24 100644 --- a/browser/components/reportbrokensite/test/browser/browser_back_buttons.js +++ b/browser/components/reportbrokensite/test/browser/browser_back_buttons.js @@ -12,26 +12,23 @@ add_common_setup(); add_task(async function testBackButtonsAreAdded() { ensureReportBrokenSitePreffedOn(); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - let rbs = await AppMenu().openReportBrokenSite(); - rbs.isBackButtonEnabled(); - await rbs.clickBack(); - await rbs.close(); - - rbs = await HelpMenu().openReportBrokenSite(); - ok(!rbs.backButton, "Back button is not shown for Help Menu"); - await rbs.close(); - - rbs = await ProtectionsPanel().openReportBrokenSite(); - rbs.isBackButtonEnabled(); - await rbs.clickBack(); - await rbs.close(); - - rbs = await HelpMenu().openReportBrokenSite(); - ok(!rbs.backButton, "Back button is not shown for Help Menu"); - await rbs.close(); - } - ); + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + let rbs = await AppMenu().openReportBrokenSite(); + rbs.isBackButtonEnabled(); + await rbs.clickBack(); + await rbs.close(); + + rbs = await HelpMenu().openReportBrokenSite(); + ok(!rbs.backButton, "Back button is not shown for Help Menu"); + await rbs.close(); + + rbs = await ProtectionsPanel().openReportBrokenSite(); + rbs.isBackButtonEnabled(); + await rbs.clickBack(); + await rbs.close(); + + rbs = await HelpMenu().openReportBrokenSite(); + ok(!rbs.backButton, "Back button is not shown for Help Menu"); + await rbs.close(); + }); }); diff --git a/browser/components/reportbrokensite/test/browser/browser_keyboard_navigation.js b/browser/components/reportbrokensite/test/browser/browser_keyboard_navigation.js index 4c37866628..3bf9278e46 100644 --- a/browser/components/reportbrokensite/test/browser/browser_keyboard_navigation.js +++ b/browser/components/reportbrokensite/test/browser/browser_keyboard_navigation.js @@ -12,34 +12,31 @@ add_common_setup(); requestLongerTimeout(2); async function testPressingKey(key, tabToMatch, makePromise, followUp) { - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - for (const menu of [AppMenu(), ProtectionsPanel(), HelpMenu()]) { - info( - `Opening RBS to test pressing ${key} for ${tabToMatch} on ${menu.menuDescription}` - ); - const rbs = await menu.openReportBrokenSite(); - const promise = makePromise(rbs); - if (tabToMatch) { - if (await tabTo(tabToMatch)) { - await pressKeyAndAwait(promise, key); - followUp && (await followUp(rbs)); - await rbs.close(); - ok(true, `was able to activate ${tabToMatch} with keyboard`); - } else { - await rbs.close(); - ok(false, `could not tab to ${tabToMatch}`); - } - } else { + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + for (const menu of [AppMenu(), ProtectionsPanel(), HelpMenu()]) { + info( + `Opening RBS to test pressing ${key} for ${tabToMatch} on ${menu.menuDescription}` + ); + const rbs = await menu.openReportBrokenSite(); + const promise = makePromise(rbs); + if (tabToMatch) { + if (await tabTo(tabToMatch)) { await pressKeyAndAwait(promise, key); followUp && (await followUp(rbs)); await rbs.close(); - ok(true, `was able to use keyboard`); + ok(true, `was able to activate ${tabToMatch} with keyboard`); + } else { + await rbs.close(); + ok(false, `could not tab to ${tabToMatch}`); } + } else { + await pressKeyAndAwait(promise, key); + followUp && (await followUp(rbs)); + await rbs.close(); + ok(true, `was able to use keyboard`); } } - ); + }); } add_task(async function testSendMoreInfo() { @@ -98,16 +95,13 @@ add_task(async function testESCOnSent() { add_task(async function testBackButtons() { ensureReportBrokenSitePreffedOn(); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - for (const menu of [AppMenu(), ProtectionsPanel()]) { - await menu.openReportBrokenSite(); - await tabTo("#report-broken-site-popup-mainView .subviewbutton-back"); - const promise = BrowserTestUtils.waitForEvent(menu.popup, "ViewShown"); - await pressKeyAndAwait(promise, "KEY_Enter"); - menu.close(); - } + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + for (const menu of [AppMenu(), ProtectionsPanel()]) { + await menu.openReportBrokenSite(); + await tabTo("#report-broken-site-popup-mainView .subviewbutton-back"); + const promise = BrowserTestUtils.waitForEvent(menu.popup, "ViewShown"); + await pressKeyAndAwait(promise, "KEY_Enter"); + menu.close(); } - ); + }); }); diff --git a/browser/components/reportbrokensite/test/browser/browser_prefers_contrast.js b/browser/components/reportbrokensite/test/browser/browser_prefers_contrast.js index 7097a662e5..68aeae911e 100644 --- a/browser/components/reportbrokensite/test/browser/browser_prefers_contrast.js +++ b/browser/components/reportbrokensite/test/browser/browser_prefers_contrast.js @@ -33,7 +33,7 @@ add_task(async function testReportSentViewBGColor() { await SpecialPowers.pushPrefEnv({ set: HIGH_CONTRAST_MODE_OFF }); const rbs = await menu.openReportBrokenSite(); const { mainView, sentView } = rbs; - mainView.style.backgroundColor = "var(--color-background-success)"; + mainView.style.backgroundColor = "var(--background-color-success)"; const expectedReportSentBGColor = defaultView.getComputedStyle(mainView).backgroundColor; mainView.style.backgroundColor = ""; diff --git a/browser/components/reportbrokensite/test/browser/browser_reason_dropdown.js b/browser/components/reportbrokensite/test/browser/browser_reason_dropdown.js index 0f5545fcc4..e6e6967919 100644 --- a/browser/components/reportbrokensite/test/browser/browser_reason_dropdown.js +++ b/browser/components/reportbrokensite/test/browser/browser_reason_dropdown.js @@ -29,45 +29,42 @@ async function clickSendAndCheckPing(rbs, expectedReason = null) { add_task(async function testReasonDropdown() { ensureReportBrokenSitePreffedOn(); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - ensureReasonDisabled(); - - let rbs = await AppMenu().openReportBrokenSite(); - await rbs.isReasonHidden(); - await rbs.isSendButtonEnabled(); - await clickSendAndCheckPing(rbs); - await rbs.clickOkay(); - - ensureReasonOptional(); - rbs = await AppMenu().openReportBrokenSite(); - await rbs.isReasonOptional(); - await rbs.isSendButtonEnabled(); - await clickSendAndCheckPing(rbs); - await rbs.clickOkay(); - - rbs = await AppMenu().openReportBrokenSite(); - await rbs.isReasonOptional(); - rbs.chooseReason("slow"); - await rbs.isSendButtonEnabled(); - await clickSendAndCheckPing(rbs, "slow"); - await rbs.clickOkay(); - - ensureReasonRequired(); - rbs = await AppMenu().openReportBrokenSite(); - await rbs.isReasonRequired(); - await rbs.isSendButtonEnabled(); - const selectPromise = BrowserTestUtils.waitForSelectPopupShown(window); - EventUtils.synthesizeMouseAtCenter(rbs.sendButton, {}, window); - await selectPromise; - rbs.chooseReason("media"); - await rbs.dismissDropdownPopup(); - await rbs.isSendButtonEnabled(); - await clickSendAndCheckPing(rbs, "media"); - await rbs.clickOkay(); - } - ); + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + ensureReasonDisabled(); + + let rbs = await AppMenu().openReportBrokenSite(); + await rbs.isReasonHidden(); + await rbs.isSendButtonEnabled(); + await clickSendAndCheckPing(rbs); + await rbs.clickOkay(); + + ensureReasonOptional(); + rbs = await AppMenu().openReportBrokenSite(); + await rbs.isReasonOptional(); + await rbs.isSendButtonEnabled(); + await clickSendAndCheckPing(rbs); + await rbs.clickOkay(); + + rbs = await AppMenu().openReportBrokenSite(); + await rbs.isReasonOptional(); + rbs.chooseReason("slow"); + await rbs.isSendButtonEnabled(); + await clickSendAndCheckPing(rbs, "slow"); + await rbs.clickOkay(); + + ensureReasonRequired(); + rbs = await AppMenu().openReportBrokenSite(); + await rbs.isReasonRequired(); + await rbs.isSendButtonEnabled(); + const selectPromise = BrowserTestUtils.waitForSelectPopupShown(window); + EventUtils.synthesizeMouseAtCenter(rbs.sendButton, {}, window); + await selectPromise; + rbs.chooseReason("media"); + await rbs.dismissDropdownPopup(); + await rbs.isSendButtonEnabled(); + await clickSendAndCheckPing(rbs, "media"); + await rbs.clickOkay(); + }); }); async function getListItems(rbs) { @@ -90,72 +87,69 @@ add_task(async function testReasonDropdownRandomized() { undefined ); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - // confirm that the default order is initially used - Services.prefs.setBoolPref(RANDOMIZE_PREF, false); - const rbs = await AppMenu().openReportBrokenSite(); - const defaultOrder = [ - "choose", - "slow", - "media", - "content", - "account", - "adblockers", - "other", - ]; - Assert.deepEqual( - await getListItems(rbs), - defaultOrder, - "non-random order is correct" - ); - - // confirm that a random order happens per user - let randomOrder; - let isRandomized = false; - Services.prefs.setBoolPref(RANDOMIZE_PREF, true); - - // This becomes ClientEnvironment.randomizationId, which we can set to - // any value which results in a different order from the default ordering. - Services.prefs.setCharPref("app.normandy.user_id", "dummy"); - - // clicking cancel triggers a reset, which is when the randomization - // logic is called. so we must click cancel after pref-changes here. + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + // confirm that the default order is initially used + Services.prefs.setBoolPref(RANDOMIZE_PREF, false); + const rbs = await AppMenu().openReportBrokenSite(); + const defaultOrder = [ + "choose", + "slow", + "media", + "content", + "account", + "adblockers", + "other", + ]; + Assert.deepEqual( + await getListItems(rbs), + defaultOrder, + "non-random order is correct" + ); + + // confirm that a random order happens per user + let randomOrder; + let isRandomized = false; + Services.prefs.setBoolPref(RANDOMIZE_PREF, true); + + // This becomes ClientEnvironment.randomizationId, which we can set to + // any value which results in a different order from the default ordering. + Services.prefs.setCharPref("app.normandy.user_id", "dummy"); + + // clicking cancel triggers a reset, which is when the randomization + // logic is called. so we must click cancel after pref-changes here. + rbs.clickCancel(); + await AppMenu().openReportBrokenSite(); + randomOrder = await getListItems(rbs); + Assert.ok( + randomOrder != defaultOrder, + "options are randomized with pref on" + ); + + // confirm that the order doesn't change per user + isRandomized = false; + for (let attempt = 0; attempt < 5; ++attempt) { rbs.clickCancel(); await AppMenu().openReportBrokenSite(); - randomOrder = await getListItems(rbs); - Assert.ok( - randomOrder != defaultOrder, - "options are randomized with pref on" - ); + const order = await getListItems(rbs); - // confirm that the order doesn't change per user - isRandomized = false; - for (let attempt = 0; attempt < 5; ++attempt) { - rbs.clickCancel(); - await AppMenu().openReportBrokenSite(); - const order = await getListItems(rbs); - - if (order != randomOrder) { - isRandomized = true; - break; - } + if (order != randomOrder) { + isRandomized = true; + break; } - Assert.ok(!isRandomized, "options keep the same order per user"); - - // confirm that the order reverts to the default if pref flipped to false - Services.prefs.setBoolPref(RANDOMIZE_PREF, false); - rbs.clickCancel(); - await AppMenu().openReportBrokenSite(); - Assert.deepEqual( - defaultOrder, - await getListItems(rbs), - "reverts to non-random order correctly" - ); - rbs.clickCancel(); } - ); + Assert.ok(!isRandomized, "options keep the same order per user"); + + // confirm that the order reverts to the default if pref flipped to false + Services.prefs.setBoolPref(RANDOMIZE_PREF, false); + rbs.clickCancel(); + await AppMenu().openReportBrokenSite(); + Assert.deepEqual( + defaultOrder, + await getListItems(rbs), + "reverts to non-random order correctly" + ); + rbs.clickCancel(); + }); Services.prefs.setCharPref(USER_ID_PREF, origNormandyUserID); }); diff --git a/browser/components/reportbrokensite/test/browser/browser_report_site_issue_fallback.js b/browser/components/reportbrokensite/test/browser/browser_report_site_issue_fallback.js index 26101d77b9..98c6c740a5 100644 --- a/browser/components/reportbrokensite/test/browser/browser_report_site_issue_fallback.js +++ b/browser/components/reportbrokensite/test/browser/browser_report_site_issue_fallback.js @@ -40,14 +40,11 @@ async function testEnabledForValidURLs(menu) { ensureReportBrokenSitePreffedOff(); ensureReportSiteIssuePreffedOn(); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - await menu.open(); - menu.isReportSiteIssueEnabled(); - await menu.close(); - } - ); + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + await menu.open(); + menu.isReportSiteIssueEnabled(); + await menu.close(); + }); } // AppMenu help sub-menu diff --git a/browser/components/reportbrokensite/test/browser/browser_send_more_info.js b/browser/components/reportbrokensite/test/browser/browser_send_more_info.js index edce03e0e0..9306f5161e 100644 --- a/browser/components/reportbrokensite/test/browser/browser_send_more_info.js +++ b/browser/components/reportbrokensite/test/browser/browser_send_more_info.js @@ -24,22 +24,19 @@ requestLongerTimeout(2); add_task(async function testSendMoreInfoPref() { ensureReportBrokenSitePreffedOn(); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - await changeTab(gBrowser.selectedTab, REPORTABLE_PAGE_URL); - - ensureSendMoreInfoDisabled(); - let rbs = await AppMenu().openReportBrokenSite(); - await rbs.isSendMoreInfoHidden(); - await rbs.close(); - - ensureSendMoreInfoEnabled(); - rbs = await AppMenu().openReportBrokenSite(); - await rbs.isSendMoreInfoShown(); - await rbs.close(); - } - ); + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + await changeTab(gBrowser.selectedTab, REPORTABLE_PAGE_URL); + + ensureSendMoreInfoDisabled(); + let rbs = await AppMenu().openReportBrokenSite(); + await rbs.isSendMoreInfoHidden(); + await rbs.close(); + + ensureSendMoreInfoEnabled(); + rbs = await AppMenu().openReportBrokenSite(); + await rbs.isSendMoreInfoShown(); + await rbs.close(); + }); }); add_task(async function testSendingMoreInfo() { diff --git a/browser/components/reportbrokensite/test/browser/browser_tab_key_order.js b/browser/components/reportbrokensite/test/browser/browser_tab_key_order.js index 3a50c9aa51..e02c6a8394 100644 --- a/browser/components/reportbrokensite/test/browser/browser_tab_key_order.js +++ b/browser/components/reportbrokensite/test/browser/browser_tab_key_order.js @@ -124,12 +124,9 @@ add_task(async function testTabOrdering() { ensureReportBrokenSitePreffedOn(); ensureSendMoreInfoEnabled(); - await BrowserTestUtils.withNewTab( - REPORTABLE_PAGE_URL, - async function (browser) { - await testTabOrder(AppMenu()); - await testTabOrder(ProtectionsPanel()); - await testTabOrder(HelpMenu()); - } - ); + await BrowserTestUtils.withNewTab(REPORTABLE_PAGE_URL, async function () { + await testTabOrder(AppMenu()); + await testTabOrder(ProtectionsPanel()); + await testTabOrder(HelpMenu()); + }); }); diff --git a/browser/components/reportbrokensite/test/browser/head.js b/browser/components/reportbrokensite/test/browser/head.js index 7cc1d51a21..84aa0f56dc 100644 --- a/browser/components/reportbrokensite/test/browser/head.js +++ b/browser/components/reportbrokensite/test/browser/head.js @@ -833,7 +833,7 @@ async function tabTo(match, win = window) { return undefined; } -async function setupStrictETP(fn) { +async function setupStrictETP() { await UrlClassifierTestUtils.addTestTrackers(); registerCleanupFunction(() => { UrlClassifierTestUtils.cleanupTestTrackers(); diff --git a/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js b/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js index 13dcec8ea1..69443db930 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js +++ b/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js @@ -160,7 +160,7 @@ async function test_dynamical_window_rounding(aWindow, aCheckFunc) { * check() functions use ok() while on Linux, we do not all ok() and instead * rely on waitForCondition to fail). * - * The logging statements in this test, and RFPHelper.jsm, help narrow down and + * The logging statements in this test, and RFPHelper.sys.mjs, help narrow down and * illustrate the issue. */ info(caseString + "We hit the weird resize bug. Resize it again."); diff --git a/browser/components/resistfingerprinting/test/browser/browser_navigator.js b/browser/components/resistfingerprinting/test/browser/browser_navigator.js index fb2c539194..2e7e76fdfb 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_navigator.js +++ b/browser/components/resistfingerprinting/test/browser/browser_navigator.js @@ -339,7 +339,7 @@ async function testWorkerNavigator() { // test in Fission. if (SpecialPowers.useRemoteSubframes) { await new Promise(resolve => { - let observer = (subject, topic, data) => { + let observer = (subject, topic) => { if (topic === "ipc:content-shutdown") { Services.obs.removeObserver(observer, "ipc:content-shutdown"); resolve(); diff --git a/browser/components/resistfingerprinting/test/browser/browser_spoofing_keyboard_event.js b/browser/components/resistfingerprinting/test/browser/browser_spoofing_keyboard_event.js index a8f9db9edc..c070c7485b 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_spoofing_keyboard_event.js +++ b/browser/components/resistfingerprinting/test/browser/browser_spoofing_keyboard_event.js @@ -2081,7 +2081,7 @@ async function testKeyEvent(aTab, aTestCase) { // a custom event 'resultAvailable' for informing the script to check the // result. await new Promise(resolve => { - function eventHandler(aEvent) { + function eventHandler() { verifyKeyboardEvent( JSON.parse(resElement.value), result, diff --git a/browser/components/resistfingerprinting/test/browser/browser_timezone.js b/browser/components/resistfingerprinting/test/browser/browser_timezone.js index d2aefff01c..13deeb5b26 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_timezone.js +++ b/browser/components/resistfingerprinting/test/browser/browser_timezone.js @@ -55,6 +55,15 @@ async function verifySpoofed() { "The hours reports in UTC timezone." ); is(date.getTimezoneOffset(), 0, "The difference with UTC timezone is 0."); + + let parser = new DOMParser(); + let doc = parser.parseFromString("<p></p>", "text/html"); + let lastModified = new Date( + doc.lastModified.replace(/(\d{2})\/(\d{2})\/(\d{4})/, "$3-$1-$2") + ); + // Use ceil to account for the time passed to run the other statements + let offset = Math.ceil((lastModified - new Date()) / 1000); + is(offset, 0, "document.lastModified does not leak the timezone."); } // Run test in the context of the page. diff --git a/browser/components/resistfingerprinting/test/browser/file_animationapi_iframee.html b/browser/components/resistfingerprinting/test/browser/file_animationapi_iframee.html index da86656bd4..758176691b 100644 --- a/browser/components/resistfingerprinting/test/browser/file_animationapi_iframee.html +++ b/browser/components/resistfingerprinting/test/browser/file_animationapi_iframee.html @@ -2,7 +2,7 @@ <head> <meta charset="utf8"> <script> -function waitForCondition(aCond, aCallback, aErrorMsg) { +function waitForCondition(aCond, aCallback) { var tries = 0; var interval = setInterval(() => { if (tries >= 30) { diff --git a/browser/components/resistfingerprinting/test/browser/file_animationapi_iframer.html b/browser/components/resistfingerprinting/test/browser/file_animationapi_iframer.html index 234661a6a9..c32bd40610 100644 --- a/browser/components/resistfingerprinting/test/browser/file_animationapi_iframer.html +++ b/browser/components/resistfingerprinting/test/browser/file_animationapi_iframer.html @@ -5,7 +5,7 @@ <title></title> <script src="shared_test_funcs.js"></script> <script> -async function runTheTest(iframe_domain, cross_origin_domain, extraData) { +async function runTheTest(iframe_domain, cross_origin_domain) { const iframes = document.querySelectorAll("iframe"); iframes[0].src = `https://${iframe_domain}/browser/browser/components/resistfingerprinting/test/browser/file_animationapi_iframee.html`; await waitForMessage("ready", `https://${iframe_domain}`); diff --git a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_popupmaker.html b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_popupmaker.html index 23fd058c44..d8788edee9 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_popupmaker.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_popupmaker.html @@ -33,7 +33,7 @@ function createPopup() { window.addEventListener("load", createPopup); console.log("TKTK: Adding initial load"); -async function runTheTest(iframe_domain, cross_origin_domain, mode) { +async function runTheTest(iframe_domain, cross_origin_domain) { await new Promise(r => setTimeout(r, 2000)); console.log("TKTK: runTheTest() popup =", (popup === undefined ? "undefined" : "something")); if (document.readyState !== 'complete') { diff --git a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_popupmaker.html b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_popupmaker.html index ae08111e61..ea38234def 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_popupmaker.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_popupmaker.html @@ -3,7 +3,7 @@ <script src="shared_test_funcs.js"></script> <script type="text/javascript"> var popup; -async function runTheTest(iframe_domain, cross_origin_domain, mode) { +async function runTheTest(iframe_domain, cross_origin_domain) { let s = `<html><script> console.log("TKTK: Loaded popup"); function give_result() { diff --git a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframer.html b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframer.html index d03c514fc7..9c52f5774a 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframer.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframer.html @@ -5,7 +5,7 @@ <title></title> <script src="shared_test_funcs.js"></script> <script> -async function runTheTest(iframe_domain, cross_origin_domain) { +async function runTheTest(iframe_domain) { // Set up the frame const iframes = document.querySelectorAll("iframe"); iframes[0].src = `https://${iframe_domain}/browser/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframee.html`; diff --git a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html index 188d78ee6e..75ae15313b 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html @@ -3,7 +3,7 @@ <script src="shared_test_funcs.js"></script> <script type="text/javascript"> var popup; -async function runTheTest(iframe_domain, cross_origin_domain, mode) { +async function runTheTest(iframe_domain, cross_origin_domain) { let s = `<!DOCTYPE html><html><script> function give_result() { return { diff --git a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframer.html b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframer.html index 3de74bc9a3..b3eb2e6ad2 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframer.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframer.html @@ -5,7 +5,7 @@ <title></title> <script src="shared_test_funcs.js"></script> <script> -async function runTheTest(iframe_domain, cross_origin_domain, mode) { +async function runTheTest(iframe_domain, cross_origin_domain) { var child_reference; let url = `https://${iframe_domain}/browser/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframee.html?mode=` let params = new URLSearchParams(document.location.search); diff --git a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_double_framee.html b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_double_framee.html index 8a4373c703..f4ea70e466 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_double_framee.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_double_framee.html @@ -3,7 +3,7 @@ <body> <output id="result"></output> <script type="text/javascript"> - window.addEventListener("load", function listener(event) { + window.addEventListener("load", function listener() { parent.postMessage(["frame_ready"], "*"); }); window.addEventListener("message", function listener(event) { diff --git a/browser/components/resistfingerprinting/test/browser/file_navigator_iframee.html b/browser/components/resistfingerprinting/test/browser/file_navigator_iframee.html index 8e312d1d7b..350d05f6aa 100644 --- a/browser/components/resistfingerprinting/test/browser/file_navigator_iframee.html +++ b/browser/components/resistfingerprinting/test/browser/file_navigator_iframee.html @@ -52,7 +52,7 @@ window.addEventListener("message", async function listener(event) { result.framee_crossOrigin_userAgentHTTPHeader = content; }); - Promise.all([one, two]).then((values) => { + Promise.all([one, two]).then(() => { parent.postMessage(result, "*") }); } diff --git a/browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframer.html b/browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframer.html index 4d9c81ec8d..499d9d8194 100644 --- a/browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframer.html +++ b/browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframer.html @@ -5,7 +5,7 @@ <title></title> <script src="shared_test_funcs.js"></script> <script> -async function runTheTest(iframe_domain, cross_origin_domain, extraData) { +async function runTheTest(iframe_domain, cross_origin_domain) { const iframes = document.querySelectorAll("iframe"); iframes[0].src = `https://${iframe_domain}/browser/browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframee.html`; await waitForMessage("ready", `https://${iframe_domain}`); diff --git a/browser/components/resistfingerprinting/test/browser/head.js b/browser/components/resistfingerprinting/test/browser/head.js index 3c3f588960..8973839220 100644 --- a/browser/components/resistfingerprinting/test/browser/head.js +++ b/browser/components/resistfingerprinting/test/browser/head.js @@ -372,9 +372,7 @@ async function testWindowOpen( aTargetWidth, aTargetHeight, aMaxAvailWidth, - aMaxAvailHeight, - aPopupChromeUIWidth, - aPopupChromeUIHeight + aMaxAvailHeight ) { // If the target size is greater than the maximum available content size, // we set the target size to it. @@ -687,7 +685,7 @@ async function runActualTest(uri, testFunction, expectedResults, extraData) { let filterExtraData = function (x) { let banned_keys = ["private_window", "etp_reload", "noopener", "await_uri"]; return Object.fromEntries( - Object.entries(x).filter(([k, v]) => !banned_keys.includes(k)) + Object.entries(x).filter(([k]) => !banned_keys.includes(k)) ); }; diff --git a/browser/components/resistfingerprinting/test/mochitest/test_geolocation.html b/browser/components/resistfingerprinting/test/mochitest/test_geolocation.html index 95394ddb56..1c8828ee9a 100644 --- a/browser/components/resistfingerprinting/test/mochitest/test_geolocation.html +++ b/browser/components/resistfingerprinting/test/mochitest/test_geolocation.html @@ -30,7 +30,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1372069 function doTest_getCurrentPosition() { navigator.geolocation.getCurrentPosition( - (position) => { + () => { ok(true, "Success callback is expected to be called"); doTest_watchPosition(); }, @@ -43,7 +43,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1372069 function doTest_watchPosition() { let wid = navigator.geolocation.watchPosition( - (position) => { + () => { ok(true, "Success callback is expected to be called"); navigator.geolocation.clearWatch(wid); SimpleTest.finish(); diff --git a/browser/components/safebrowsing/content/test/browser_whitelisted.js b/browser/components/safebrowsing/content/test/browser_whitelisted.js index 92c42a5b52..eb217d618a 100644 --- a/browser/components/safebrowsing/content/test/browser_whitelisted.js +++ b/browser/components/safebrowsing/content/test/browser_whitelisted.js @@ -12,7 +12,7 @@ registerCleanupFunction(function () { } }); -function testBlockedPage(window) { +function testBlockedPage() { info("Non-whitelisted pages must be blocked"); ok(true, "about:blocked was shown"); } diff --git a/browser/components/safebrowsing/content/test/head.js b/browser/components/safebrowsing/content/test/head.js index 145833d010..ffbdb18d15 100644 --- a/browser/components/safebrowsing/content/test/head.js +++ b/browser/components/safebrowsing/content/test/head.js @@ -1,4 +1,4 @@ -// This url must sync with the table, url in SafeBrowsing.jsm addMozEntries +// This url must sync with the table, url in SafeBrowsing.sys.mjs addMozEntries const PHISH_TABLE = "moztest-phish-simple"; const PHISH_URL = "https://www.itisatrap.org/firefox/its-a-trap.html"; diff --git a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs index bcb3199902..3718b6a4e0 100644 --- a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs +++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs @@ -37,6 +37,7 @@ import { import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { ShortcutUtils } from "resource://gre/modules/ShortcutUtils.sys.mjs"; const STATES = { CROSSHAIRS: "crosshairs", @@ -49,7 +50,7 @@ const STATES = { const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "overlayLocalization", () => { - return new Localization(["browser/screenshotsOverlay.ftl"], true); + return new Localization(["browser/screenshots.ftl"], true); }); const SCREENSHOTS_LAST_SAVED_METHOD_PREF = @@ -79,13 +80,33 @@ export class ScreenshotsOverlay { #methodsUsed; get markup() { - let [cancel, instructions, download, copy] = - lazy.overlayLocalization.formatMessagesSync([ - { id: "screenshots-overlay-cancel-button" }, - { id: "screenshots-overlay-instructions" }, - { id: "screenshots-overlay-download-button" }, - { id: "screenshots-overlay-copy-button" }, - ]); + let accelString = ShortcutUtils.getModifierString("accel"); + let copyShorcut = accelString + this.copyKey; + let downloadShortcut = accelString + this.downloadKey; + + let [ + cancelLabel, + cancelAttributes, + instructions, + downloadLabel, + downloadAttributes, + copyLabel, + copyAttributes, + ] = lazy.overlayLocalization.formatMessagesSync([ + { id: "screenshots-cancel-button" }, + { id: "screenshots-component-cancel-button" }, + { id: "screenshots-instructions" }, + { id: "screenshots-component-download-button-label" }, + { + id: "screenshots-component-download-button", + args: { shortcut: downloadShortcut }, + }, + { id: "screenshots-component-copy-button-label" }, + { + id: "screenshots-component-copy-button", + args: { shortcut: copyShorcut }, + }, + ]); return ` <template> @@ -98,7 +119,7 @@ export class ScreenshotsOverlay { <div class="face"></div> </div> <div class="preview-instructions">${instructions.value}</div> - <button class="screenshots-button ghost-button" id="screenshots-cancel-button">${cancel.value}</button> + <button class="screenshots-button ghost-button" id="screenshots-cancel-button" title="${cancelAttributes.attributes[0].value}" aria-label="${cancelAttributes.attributes[1].value}">${cancelLabel.value}</button> </div> <div id="hover-highlight" hidden></div> <div id="selection-container" hidden> @@ -138,9 +159,9 @@ export class ScreenshotsOverlay { </div> <div id="buttons-container" hidden> <div class="buttons-wrapper"> - <button id="cancel" class="screenshots-button" title="${cancel.value}" aria-label="${cancel.value}" tabindex="0"><img/></button> - <button id="copy" class="screenshots-button" title="${copy.value}" aria-label="${copy.value}" tabindex="0"><img/>${copy.value}</button> - <button id="download" class="screenshots-button primary" title="${download.value}" aria-label="${download.value}" tabindex="0"><img/>${download.value}</button> + <button id="cancel" class="screenshots-button" title="${cancelAttributes.attributes[0].value}" aria-label="${cancelAttributes.attributes[1].value}"><img/></button> + <button id="copy" class="screenshots-button" title="${copyAttributes.attributes[0].value}" aria-label="${copyAttributes.attributes[1].value}"><img/><label>${copyLabel.value}</label></button> + <button id="download" class="screenshots-button primary" title="${downloadAttributes.attributes[0].value}" aria-label="${downloadAttributes.attributes[1].value}"><img/><label>${downloadLabel.value}</label></button> </div> </div> </div> @@ -180,6 +201,14 @@ export class ScreenshotsOverlay { this.selectionRegion = new Region(this.windowDimensions); this.hoverElementRegion = new Region(this.windowDimensions); this.resetMethodsUsed(); + + let [downloadKey, copyKey] = lazy.overlayLocalization.formatMessagesSync([ + { id: "screenshots-component-download-key" }, + { id: "screenshots-component-copy-key" }, + ]); + + this.downloadKey = downloadKey.value; + this.copyKey = copyKey.value; } get content() { @@ -204,6 +233,9 @@ export class ScreenshotsOverlay { this.#content.root.appendChild(this.fragment); this.initializeElements(); + this.screenshotsContainer.dir = Services.locale.isAppLocaleRTL + ? "rtl" + : "ltr"; await this.updateWindowDimensions(); this.#setState(STATES.CROSSHAIRS); @@ -290,10 +322,6 @@ export class ScreenshotsOverlay { } handleEvent(event) { - if (event.button > 0) { - return; - } - switch (event.type) { case "click": this.handleClick(event); @@ -316,21 +344,46 @@ export class ScreenshotsOverlay { } } + /** + * If the event came from the primary button, return false as we should not + * early return in the event handler function. + * If the event had another button, set to the crosshairs or selected state + * and return true to early return from the event handler function. + * @param {PointerEvent} event + * @returns true if the event button(s) was the non primary button + * false otherwise + */ + preEventHandler(event) { + if (event.button > 0 || event.buttons > 1) { + switch (this.#state) { + case STATES.DRAGGING_READY: + this.#setState(STATES.CROSSHAIRS); + break; + case STATES.DRAGGING: + case STATES.RESIZING: + this.#setState(STATES.SELECTED); + break; + } + return true; + } + return false; + } + handleClick(event) { + if (this.preEventHandler(event)) { + return; + } + switch (event.originalTarget.id) { case "screenshots-cancel-button": case "cancel": this.maybeCancelScreenshots(); break; case "copy": - this.#dispatchEvent("Screenshots:Copy", { - region: this.selectionRegion.dimensions, - }); + this.copySelectedRegion(); break; case "download": - this.#dispatchEvent("Screenshots:Download", { - region: this.selectionRegion.dimensions, - }); + this.downloadSelectedRegion(); break; } } @@ -351,6 +404,16 @@ export class ScreenshotsOverlay { * @param {Event} event The pointerown event */ handlePointerDown(event) { + // Early return if the event target is not within the screenshots component + // element. + if (!event.originalTarget.closest("#screenshots-component")) { + return; + } + + if (this.preEventHandler(event)) { + return; + } + if ( event.originalTarget.id === "screenshots-cancel-button" || event.originalTarget.closest("#buttons-container") === @@ -379,6 +442,10 @@ export class ScreenshotsOverlay { * @param {Event} event The pointermove event */ handlePointerMove(event) { + if (this.preEventHandler(event)) { + return; + } + const { pageX, pageY, clientX, clientY } = this.getCoordinatesFromEvent(event); @@ -450,6 +517,18 @@ export class ScreenshotsOverlay { case "Escape": this.maybeCancelScreenshots(); break; + case this.copyKey.toLowerCase(): + if (this.state === "selected" && this.getAccelKey(event)) { + event.preventDefault(); + this.copySelectedRegion(); + } + break; + case this.downloadKey.toLowerCase(): + if (this.state === "selected" && this.getAccelKey(event)) { + event.preventDefault(); + this.downloadSelectedRegion(); + } + break; } } @@ -780,9 +859,9 @@ export class ScreenshotsOverlay { */ setFocusToActionButton() { if (lazy.SCREENSHOTS_LAST_SAVED_METHOD === "copy") { - this.copyButton.focus({ focusVisible: true }); + this.copyButton.focus({ focusVisible: true, preventScroll: true }); } else { - this.downloadButton.focus({ focusVisible: true }); + this.downloadButton.focus({ focusVisible: true, preventScroll: true }); } } @@ -868,6 +947,18 @@ export class ScreenshotsOverlay { } } + copySelectedRegion() { + this.#dispatchEvent("Screenshots:Copy", { + region: this.selectionRegion.dimensions, + }); + } + + downloadSelectedRegion() { + this.#dispatchEvent("Screenshots:Download", { + region: this.selectionRegion.dimensions, + }); + } + /** * Hide hover element, selection and buttons containers. * Show the preview container and the panel. @@ -1285,17 +1376,21 @@ export class ScreenshotsOverlay { this.updateSelectionSizeText(); } + /** + * Update the size of the selected region. Use the zoom to correctly display + * the region dimensions. + */ updateSelectionSizeText() { - let dpr = this.windowDimensions.devicePixelRatio; let { width, height } = this.selectionRegion.dimensions; + let zoom = Math.round(this.window.browsingContext.fullZoom * 100) / 100; let [selectionSizeTranslation] = lazy.overlayLocalization.formatMessagesSync([ { - id: "screenshots-overlay-selection-region-size", + id: "screenshots-overlay-selection-region-size-2", args: { - width: Math.floor(width * dpr), - height: Math.floor(height * dpr), + width: Math.floor(width * zoom), + height: Math.floor(height * zoom), }, }, ]); diff --git a/browser/components/screenshots/ScreenshotsUtils.sys.mjs b/browser/components/screenshots/ScreenshotsUtils.sys.mjs index fc84facee3..9df74a4359 100644 --- a/browser/components/screenshots/ScreenshotsUtils.sys.mjs +++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs @@ -817,8 +817,9 @@ export var ScreenshotsUtils = { let dialog = await this.openPreviewDialog(browser); await dialog._dialogReady; - let screenshotsUI = - dialog._frame.contentDocument.createElement("screenshots-ui"); + let screenshotsUI = dialog._frame.contentDocument.createElement( + "screenshots-preview" + ); dialog._frame.contentDocument.body.appendChild(screenshotsUI); screenshotsUI.focusButton(lazy.SCREENSHOTS_LAST_SAVED_METHOD); diff --git a/browser/components/screenshots/content/screenshots.css b/browser/components/screenshots/content/screenshots.css index 506f3658c9..b155c294f8 100644 --- a/browser/components/screenshots/content/screenshots.css +++ b/browser/components/screenshots/content/screenshots.css @@ -30,13 +30,12 @@ body { display: flex; align-items: center; justify-content: center; + gap: var(--space-xsmall); cursor: pointer; text-align: center; user-select: none; white-space: nowrap; - min-height: 36px; - font-size: 15px; - min-width: 36px; + min-width: 32px; } .preview-button > img { @@ -44,11 +43,23 @@ body { fill: currentColor; width: 16px; height: 16px; + pointer-events: none; +} + +#retry > img { + content: url("chrome://global/skin/icons/reload.svg"); +} + +#cancel > img { + content: url("chrome://global/skin/icons/close.svg"); } -#download > img, #copy > img { - margin-inline-end: 5px; + content: url("chrome://global/skin/icons/edit-copy.svg"); +} + +#download > img { + content: url("chrome://browser/skin/downloads/downloads.svg"); } .preview-image { diff --git a/browser/components/screenshots/content/screenshots.html b/browser/components/screenshots/content/screenshots.html index 88c71fb4fe..fea032700c 100644 --- a/browser/components/screenshots/content/screenshots.html +++ b/browser/components/screenshots/content/screenshots.html @@ -31,32 +31,34 @@ <button id="retry" class="preview-button" - data-l10n-id="screenshots-retry-button-title" + data-l10n-id="screenshots-component-retry-button" > - <img src="chrome://global/skin/icons/reload.svg" /> + <img /> </button> <button id="cancel" class="preview-button" - data-l10n-id="screenshots-cancel-button-title" + data-l10n-id="screenshots-component-cancel-button" > - <img src="chrome://global/skin/icons/close.svg" /> + <img /> </button> <button id="copy" class="preview-button" - data-l10n-id="screenshots-copy-button-title" + data-l10n-id="screenshots-component-copy-button" > - <img src="chrome://global/skin/icons/edit-copy.svg" /> - <span data-l10n-id="screenshots-copy-button" /> + <img /><label + data-l10n-id="screenshots-component-copy-button-label" + ></label> </button> <button id="download" class="preview-button primary" - data-l10n-id="screenshots-download-button-title" + data-l10n-id="screenshots-component-download-button" > - <img src="chrome://browser/skin/downloads/downloads.svg" /> - <span data-l10n-id="screenshots-download-button" /> + <img /><label + data-l10n-id="screenshots-component-download-button-label" + ></label> </button> </div> <div class="preview-image"> diff --git a/browser/components/screenshots/content/screenshots.js b/browser/components/screenshots/content/screenshots.js index 9e47570e07..8159206d18 100644 --- a/browser/components/screenshots/content/screenshots.js +++ b/browser/components/screenshots/content/screenshots.js @@ -6,15 +6,35 @@ "use strict"; ChromeUtils.defineESModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", }); -class ScreenshotsUI extends HTMLElement { +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => { + return new Localization(["browser/screenshots.ftl"], true); +}); + +class ScreenshotsPreview extends HTMLElement { constructor() { super(); // we get passed the <browser> as a param via TabDialogBox.open() this.openerBrowser = window.arguments[0]; + + window.ensureCustomElements("moz-button"); + + let [downloadKey, copyKey] = + lazy.screenshotsLocalization.formatMessagesSync([ + { id: "screenshots-component-download-key" }, + { id: "screenshots-component-copy-key" }, + ]); + + this.downloadKey = downloadKey.value; + this.copyKey = copyKey.value; } + async connectedCallback() { this.initialize(); } @@ -38,6 +58,29 @@ class ScreenshotsUI extends HTMLElement { this._copyButton.addEventListener("click", this); this._downloadButton = this.querySelector("#download"); this._downloadButton.addEventListener("click", this); + + let accelString = ShortcutUtils.getModifierString("accel"); + let copyShorcut = accelString + this.copyKey; + let downloadShortcut = accelString + this.downloadKey; + + document.l10n.setAttributes( + this._cancelButton, + "screenshots-component-cancel-button" + ); + + document.l10n.setAttributes( + this._copyButton, + "screenshots-component-copy-button", + { shortcut: copyShorcut } + ); + + document.l10n.setAttributes( + this._downloadButton, + "screenshots-component-download-button", + { shortcut: downloadShortcut } + ); + + window.addEventListener("keydown", this, true); } close() { @@ -45,31 +88,68 @@ class ScreenshotsUI extends HTMLElement { window.close(); } - async handleEvent(event) { - if (event.type == "click" && event.currentTarget == this._cancelButton) { - this.close(); - ScreenshotsUtils.recordTelemetryEvent("canceled", "preview_cancel", {}); - } else if ( - event.type == "click" && - event.currentTarget == this._copyButton - ) { - this.saveToClipboard( - this.ownerDocument.getElementById("placeholder-image").src - ); - } else if ( - event.type == "click" && - event.currentTarget == this._downloadButton - ) { - await this.saveToFile( - this.ownerDocument.getElementById("placeholder-image").src - ); - } else if ( - event.type == "click" && - event.currentTarget == this._retryButton - ) { - ScreenshotsUtils.scheduleRetry(this.openerBrowser, "preview_retry"); - this.close(); + handleEvent(event) { + switch (event.type) { + case "click": + this.handleClick(event); + break; + case "keydown": + this.handleKeydown(event); + break; + } + } + + handleClick(event) { + switch (event.target.id) { + case "retry": + ScreenshotsUtils.scheduleRetry(this.openerBrowser, "preview_retry"); + this.close(); + break; + case "cancel": + this.close(); + ScreenshotsUtils.recordTelemetryEvent("canceled", "preview_cancel", {}); + break; + case "copy": + this.saveToClipboard( + this.ownerDocument.getElementById("placeholder-image").src + ); + break; + case "download": + this.saveToFile( + this.ownerDocument.getElementById("placeholder-image").src + ); + break; + } + } + + handleKeydown(event) { + switch (event.key) { + case this.copyKey.toLowerCase(): + if (this.getAccelKey(event)) { + event.preventDefault(); + event.stopPropagation(); + this.saveToClipboard( + this.ownerDocument.getElementById("placeholder-image").src + ); + } + break; + case this.downloadKey.toLowerCase(): + if (this.getAccelKey(event)) { + event.preventDefault(); + event.stopPropagation(); + this.saveToFile( + this.ownerDocument.getElementById("placeholder-image").src + ); + } + break; + } + } + + getAccelKey(event) { + if (AppConstants.platform === "macosx") { + return event.metaKey; } + return event.ctrlKey; } async saveToFile(dataUrl) { @@ -102,4 +182,4 @@ class ScreenshotsUI extends HTMLElement { } } } -customElements.define("screenshots-ui", ScreenshotsUI); +customElements.define("screenshots-preview", ScreenshotsPreview); diff --git a/browser/components/screenshots/overlay/overlay.css b/browser/components/screenshots/overlay/overlay.css index 6eeda8b44c..b042f0b0c2 100644 --- a/browser/components/screenshots/overlay/overlay.css +++ b/browser/components/screenshots/overlay/overlay.css @@ -6,6 +6,12 @@ :host { display: contents; + + /* These z-indexes are used to correctly layer elements in the screenshots overlay */ + --screenshots-lowest-layer: 1; + --screenshots-low-layer: 2; + --screenshots-high-layer: 3; + --screenshots-highest-layer: 4; } [hidden] { @@ -57,6 +63,7 @@ position: absolute; margin: 10px 0; cursor: auto; + z-index: var(--screenshots-highest-layer); } #selection-size, @@ -77,11 +84,13 @@ .screenshots-button { display: inline-flex; align-items: center; + justify-content: center; + gap: var(--space-xsmall); cursor: pointer; text-align: center; user-select: none; white-space: nowrap; - z-index: 6; + z-index: var(--screenshots-highest-layer); min-width: 32px; margin-inline: 4px; } @@ -90,6 +99,7 @@ width: 100%; height: 100%; pointer-events: none; + z-index: var(--screenshots-lowest-layer); } #screenshots-cancel-button { @@ -123,6 +133,10 @@ pointer-events: none; } +.screenshots-button > label { + pointer-events: none; +} + #cancel > img { content: url("chrome://global/skin/icons/close.svg"); } @@ -135,15 +149,14 @@ content: url("chrome://browser/skin/downloads/downloads.svg"); } -#download > img, -#copy > img { - margin-inline-end: 5px; -} - .face-container { position: relative; width: 64px; height: 64px; + + @media (prefers-contrast) { + display: none; + } } .face { @@ -172,7 +185,7 @@ border-radius: 50%; inset-inline-start: 2px; top: 4px; - z-index: 10; + z-index: var(--screenshots-high-layer); } .left { @@ -209,7 +222,7 @@ box-sizing: border-box; pointer-events: none; position: absolute; - z-index: 11; + z-index: var(--screenshots-high-layer); } #top-background { @@ -242,7 +255,7 @@ cursor: move; position: absolute; pointer-events: auto; - z-index: 2; + z-index: var(--screenshots-lowest-layer); outline-offset: 8px; } @@ -251,7 +264,7 @@ align-items: center; justify-content: center; position: absolute; - z-index: 5; + z-index: var(--screenshots-high-layer); pointer-events: auto; outline-offset: -15px; } @@ -270,7 +283,7 @@ inset-inline-start: 0; top: -30px; width: 100%; - z-index: 4; + z-index: var(--screenshots-low-layer); } .mover-target.direction-topRight { @@ -287,7 +300,7 @@ left: -30px; top: 0; width: 60px; - z-index: 4; + z-index: var(--screenshots-low-layer); } .mover-target.direction-right { @@ -296,7 +309,7 @@ right: -30px; top: 0; width: 60px; - z-index: 4; + z-index: var(--screenshots-low-layer); } .mover-target.direction-bottomLeft { @@ -313,7 +326,7 @@ height: 60px; inset-inline-start: 0; width: 100%; - z-index: 4; + z-index: var(--screenshots-low-layer); } .mover-target.direction-bottomRight { diff --git a/browser/components/screenshots/screenshots-buttons.css b/browser/components/screenshots/screenshots-buttons.css index 82b075bccb..b63308d8b4 100644 --- a/browser/components/screenshots/screenshots-buttons.css +++ b/browser/components/screenshots/screenshots-buttons.css @@ -25,7 +25,8 @@ .full-page, .visible-page { -moz-context-properties: fill, stroke; fill: currentColor; - stroke: var(--color-accent-primary); + /* stroke is the secondary fill color used to define the viewport shape in the SVGs */ + stroke: var(--color-gray-60); background-position: center top; background-repeat: no-repeat; background-size: 46px 46px; diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js index 864505ae2f..9ac8dab2cf 100644 --- a/browser/components/screenshots/screenshots-buttons.js +++ b/browser/components/screenshots/screenshots-buttons.js @@ -13,28 +13,40 @@ }); class ScreenshotsButtons extends MozXULElement { + static #template = null; + static get markup() { return ` - <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> - <html:link rel="stylesheet" href="chrome://browser/content/screenshots/screenshots-buttons.css"/> - <html:button class="visible-page footer-button" data-l10n-id="screenshots-save-visible-button"></html:button> - <html:button class="full-page footer-button" data-l10n-id="screenshots-save-page-button"></html:button> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link rel="stylesheet" href="chrome://browser/content/screenshots/screenshots-buttons.css" /> + <html:moz-button-group> + <html:button class="visible-page footer-button" data-l10n-id="screenshots-save-visible-button"></html:button> + <html:button class="full-page footer-button primary" data-l10n-id="screenshots-save-page-button"></html:button> + </html:moz-button-group> `; } + static get fragment() { + if (!ScreenshotsButtons.#template) { + ScreenshotsButtons.#template = MozXULElement.parseXULToFragment( + ScreenshotsButtons.markup + ); + } + return ScreenshotsButtons.#template; + } + connectedCallback() { const shadowRoot = this.attachShadow({ mode: "open" }); document.l10n.connectRoot(shadowRoot); - let fragment = MozXULElement.parseXULToFragment(this.constructor.markup); - this.shadowRoot.append(fragment); + this.shadowRoot.append(ScreenshotsButtons.fragment); - let visibleButton = shadowRoot.querySelector(".visible-page"); + let visibleButton = this.shadowRoot.querySelector(".visible-page"); visibleButton.onclick = function () { ScreenshotsUtils.doScreenshot(gBrowser.selectedBrowser, "visible"); }; - let fullpageButton = shadowRoot.querySelector(".full-page"); + let fullpageButton = this.shadowRoot.querySelector(".full-page"); fullpageButton.onclick = function () { ScreenshotsUtils.doScreenshot(gBrowser.selectedBrowser, "full_page"); }; @@ -49,7 +61,8 @@ * This will default to the visible page button. * @param {String} buttonToFocus */ - focusButton(buttonToFocus) { + async focusButton(buttonToFocus) { + await this.shadowRoot.querySelector("moz-button-group").updateComplete; if (buttonToFocus === "fullpage") { this.shadowRoot .querySelector(".full-page") diff --git a/browser/components/screenshots/tests/browser/browser.toml b/browser/components/screenshots/tests/browser/browser.toml index b27d28c677..97e7474fa3 100644 --- a/browser/components/screenshots/tests/browser/browser.toml +++ b/browser/components/screenshots/tests/browser/browser.toml @@ -18,6 +18,8 @@ prefs = [ ["browser_iframe_test.js"] skip-if = ["os == 'linux'"] +["browser_keyboard_shortcuts.js"] + ["browser_overlay_keyboard_test.js"] ["browser_screenshots_drag_scroll_test.js"] @@ -63,3 +65,5 @@ skip-if = ["!crashreporter"] ["browser_test_moving_tab_to_new_window.js"] ["browser_test_resize.js"] + +["browser_test_selection_size_text.js"] diff --git a/browser/components/screenshots/tests/browser/browser_iframe_test.js b/browser/components/screenshots/tests/browser/browser_iframe_test.js index bb853fbe28..24f7a71dca 100644 --- a/browser/components/screenshots/tests/browser/browser_iframe_test.js +++ b/browser/components/screenshots/tests/browser/browser_iframe_test.js @@ -90,7 +90,7 @@ add_task(async function test_selectingElementsInIframes() { await helper.waitForHoverElementRect(el.width, el.height); mouse.click(x, y); - await helper.waitForStateChange("selected"); + await helper.waitForStateChange(["selected"]); let dimensions = await helper.getSelectionRegionDimensions(); @@ -116,7 +116,7 @@ add_task(async function test_selectingElementsInIframes() { ); mouse.click(500, 500); - await helper.waitForStateChange("crosshairs"); + await helper.waitForStateChange(["crosshairs"]); } } ); diff --git a/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js b/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js new file mode 100644 index 0000000000..bca96f333f --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_download_shortcut() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.useDownloadDir", true]], + }); + + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + // First ensure we catch the download finishing. + let downloadFinishedPromise = new Promise(resolve => { + publicDownloads.addView({ + onDownloadChanged(download) { + info("Download changed!"); + if (download.succeeded || download.error) { + info("Download succeeded or errored"); + publicDownloads.removeView(this); + resolve(download); + } + }, + }); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 500, 500); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + + await SpecialPowers.spawn(browser, [], async () => { + EventUtils.synthesizeKey("s", { accelKey: true }, content); + }); + + info("wait for download to finish"); + let download = await downloadFinishedPromise; + + ok(download.succeeded, "Download should succeed"); + + await publicDownloads.removeFinished(); + await screenshotExit; + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + let visibleButton = await helper.getPanelButton(".visible-page"); + visibleButton.click(); + + await screenshotReady; + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + + EventUtils.synthesizeKey("s", { accelKey: true }); + + info("wait for download to finish"); + download = await downloadFinishedPromise; + + ok(download.succeeded, "Download should succeed"); + + await publicDownloads.removeFinished(); + await screenshotExit; + } + ); +}); + +add_task(async function test_copy_shortcut() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 500, 500); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + let clipboardChanged = helper.waitForRawClipboardChange(490, 490); + + await SpecialPowers.spawn(browser, [], async () => { + EventUtils.synthesizeKey("c", { accelKey: true }, content); + }); + + await clipboardChanged; + await screenshotExit; + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + let visibleButton = await helper.getPanelButton(".visible-page"); + visibleButton.click(); + + await screenshotReady; + + clipboardChanged = helper.waitForRawClipboardChange( + contentInfo.clientWidth, + contentInfo.clientHeight + ); + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + + EventUtils.synthesizeKey("c", { accelKey: true }); + + await clipboardChanged; + await screenshotExit; + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js index 757d721268..86940a5203 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js @@ -353,8 +353,6 @@ add_task(async function test_scrollIfByEdge() { await helper.scrollContentWindow(windowX, windowY); - await TestUtils.waitForTick(); - helper.triggerUIFromToolbar(); await helper.waitForOverlay(); @@ -363,17 +361,18 @@ add_task(async function test_scrollIfByEdge() { is(scrollX, windowX, "Window x position is 1000"); is(scrollY, windowY, "Window y position is 1000"); - let startX = 1100; - let startY = 1100; + let startX = 1200; + let startY = 1200; let endX = 1010; let endY = 1010; - // The window won't scroll if the state is draggingReady so we move to - // get into the dragging state and then move again to scroll the window - mouse.down(startX, startY); - await helper.assertStateChange("draggingReady"); - mouse.move(1050, 1050); - await helper.assertStateChange("dragging"); + await helper.dragOverlay(startX, startY, endX + 20, endY + 20); + await helper.scrollContentWindow(windowX, windowY); + + await TestUtils.waitForTick(); + + mouse.down(endX + 20, endY + 20); + await helper.assertStateChange("resizing"); mouse.move(endX, endY); mouse.up(endX, endY); await helper.assertStateChange("selected"); @@ -387,26 +386,64 @@ add_task(async function test_scrollIfByEdge() { is(scrollX, windowX, "Window x position is 990"); is(scrollY, windowY, "Window y position is 990"); + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + helper.triggerUIFromToolbar(); + await screenshotExit; + } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let windowX = 1000; + let windowY = 1000; + + await helper.scrollContentWindow(windowX, windowY); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + let contentInfo = await helper.getContentDimensions(); + let { scrollX, scrollY, clientWidth, clientHeight } = contentInfo; + + let startX = windowX + clientWidth - 200; + let startY = windowX + clientHeight - 200; + let endX = windowX + clientWidth - 10; + let endY = windowY + clientHeight - 10; - endX = windowX + contentInfo.clientWidth - 10; - endY = windowY + contentInfo.clientHeight - 10; + await helper.dragOverlay(startX, startY, endX - 20, endY - 20); + await helper.scrollContentWindow(windowX, windowY); + + await TestUtils.waitForTick(); info( `starting to drag overlay to ${endX}, ${endY} in test\nclientInfo: ${JSON.stringify( contentInfo )}\n` ); - await helper.dragOverlay(startX, startY, endX, endY, "selected"); + mouse.down(endX - 20, endY - 20); + await helper.assertStateChange("resizing"); + mouse.move(endX, endY); + mouse.up(endX, endY); + await helper.assertStateChange("selected"); - windowX = 1000; - windowY = 1000; + windowX = 1010; + windowY = 1010; await helper.waitForScrollTo(windowX, windowY); ({ scrollX, scrollY } = await helper.getContentDimensions()); - is(scrollX, windowX, "Window x position is 1000"); - is(scrollY, windowY, "Window y position is 1000"); + is(scrollX, windowX, "Window x position is 1010"); + is(scrollY, windowY, "Window y position is 1010"); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + helper.triggerUIFromToolbar(); + await screenshotExit; } ); }); @@ -428,13 +465,19 @@ add_task(async function test_scrollIfByEdgeWithKeyboard() { helper.triggerUIFromToolbar(); await helper.waitForOverlay(); - let { scrollX, scrollY, clientWidth, clientHeight } = - await helper.getContentDimensions(); + let { scrollX, scrollY } = await helper.getContentDimensions(); is(scrollX, windowX, "Window x position is 1000"); is(scrollY, windowY, "Window y position is 1000"); - await helper.dragOverlay(1020, 1020, 1120, 1120); + await helper.dragOverlay( + scrollX + 20, + scrollY + 20, + scrollX + 120, + scrollY + 120 + ); + + await helper.scrollContentWindow(windowX, windowY); await helper.moveOverlayViaKeyboard("highlight", [ { key: "ArrowLeft", options: { shiftKey: true } }, @@ -447,14 +490,36 @@ add_task(async function test_scrollIfByEdgeWithKeyboard() { windowY = 989; await helper.waitForScrollTo(windowX, windowY); - ({ scrollX, scrollY, clientWidth, clientHeight } = - await helper.getContentDimensions()); + ({ scrollX, scrollY } = await helper.getContentDimensions()); is(scrollX, windowX, "Window x position is 989"); is(scrollY, windowY, "Window y position is 989"); - mouse.click(1200, 1200); - await helper.assertStateChange("crosshairs"); + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + helper.triggerUIFromToolbar(); + await screenshotExit; + } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let windowX = 989; + let windowY = 989; + + await helper.scrollContentWindow(windowX, windowY); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let { scrollX, scrollY, clientWidth, clientHeight } = + await helper.getContentDimensions(); + await helper.dragOverlay( scrollX + clientWidth - 100 - 20, scrollY + clientHeight - 100 - 20, @@ -477,6 +542,10 @@ add_task(async function test_scrollIfByEdgeWithKeyboard() { is(scrollX, windowX, "Window x position is 1000"); is(scrollY, windowY, "Window y position is 1000"); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + helper.triggerUIFromToolbar(); + await screenshotExit; } ); }); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js index 605e0ae75c..3cef2dbd72 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js @@ -442,7 +442,7 @@ add_task(async function resizeAllCorners() { /** * This function tests clicking the overlay with the different mouse buttons */ -add_task(async function test_otherMouseButtons() { +add_task(async function test_clickingOtherMouseButtons() { await BrowserTestUtils.withNewTab( { gBrowser, @@ -478,6 +478,7 @@ add_task(async function test_otherMouseButtons() { mouse.down(10, 10, { button: 2 }); mouse.move(100, 100, { button: 2 }); + mouse.up(100, 100, { button: 2 }); await TestUtils.waitForTick(); @@ -486,3 +487,66 @@ add_task(async function test_otherMouseButtons() { } ); }); + +/** + * This function tests dragging the overlay with the different mouse buttons + */ +add_task(async function test_draggingOtherMouseButtons() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + // Click with button 1 in dragging state + mouse.down(100, 100); + await helper.assertStateChange("draggingReady"); + mouse.move(200, 200); + await helper.assertStateChange("dragging"); + mouse.click(200, 200, { button: 1 }); + await helper.assertStateChange("selected"); + + // Reset + mouse.click(10, 10); + await helper.assertStateChange("crosshairs"); + + // Mouse down with button 2 in draggingReady state + mouse.down(100, 100); + await helper.assertStateChange("draggingReady"); + mouse.down(200, 200, { button: 2 }); + await helper.assertStateChange("crosshairs"); + + await helper.dragOverlay(100, 100, 200, 200); + + // Click with button 1 in resizing state + mouse.down(200, 200); + await helper.assertStateChange("resizing"); + mouse.click(200, 200, { button: 1 }); + + // Reset + mouse.click(10, 10); + await helper.assertStateChange("crosshairs"); + + await helper.dragOverlay(100, 100, 200, 200); + + // Mouse down with button 2 in dragging state + mouse.down(200, 200); + await helper.assertStateChange("resizing"); + mouse.down(200, 200, { button: 2 }); + + // Reset + mouse.click(10, 10); + await helper.assertStateChange("crosshairs"); + + // Mouse move with button 2 in draggingReady state + mouse.down(100, 100); + await helper.assertStateChange("draggingReady"); + mouse.move(100, 100, { button: 2 }); + await helper.assertStateChange("crosshairs"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js index ad262a7e67..021a37b5c9 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js @@ -31,7 +31,7 @@ add_task(async function test_toggling_screenshots_pref() { .callsFake(observerSpy); let notifierStub = sinon .stub(ScreenshotsUtils, "notify") - .callsFake(function (window, type) { + .callsFake(function () { notifierSpy(); ScreenshotsUtils.notify.wrappedMethod.apply(this, arguments); }); diff --git a/browser/components/screenshots/tests/browser/browser_test_element_picker.js b/browser/components/screenshots/tests/browser/browser_test_element_picker.js index 17ed2a0190..3e2069134e 100644 --- a/browser/components/screenshots/tests/browser/browser_test_element_picker.js +++ b/browser/components/screenshots/tests/browser/browser_test_element_picker.js @@ -43,14 +43,14 @@ add_task(async function test_element_picker() { ); mouse.click(10, 10); - await helper.waitForStateChange("crosshairs"); + await helper.waitForStateChange(["crosshairs"]); let hoverElementRegionValid = await helper.isHoverElementRegionValid(); ok(!hoverElementRegionValid, "Hover element rect is null"); mouse.click(10, 10); - await helper.waitForStateChange("crosshairs"); + await helper.waitForStateChange(["crosshairs"]); } ); }); diff --git a/browser/components/screenshots/tests/browser/browser_test_selection_size_text.js b/browser/components/screenshots/tests/browser/browser_test_selection_size_text.js new file mode 100644 index 0000000000..38d1acbea9 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_test_selection_size_text.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_selectionSizeTest() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + const dpr = browser.ownerGlobal.devicePixelRatio; + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + await helper.dragOverlay(100, 100, 500, 500); + + let actualText = await helper.getOverlaySelectionSizeText(); + + Assert.equal( + actualText, + `${400 * dpr} x ${400 * dpr}`, + "The selection size text is the same" + ); + } + ); +}); + +add_task(async function test_selectionSizeTestAt1Point5Zoom() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + const zoom = 1.5; + const dpr = browser.ownerGlobal.devicePixelRatio; + let helper = new ScreenshotsHelper(browser); + helper.zoomBrowser(zoom); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + await helper.dragOverlay(100, 100, 500, 500); + + let actualText = await helper.getOverlaySelectionSizeText(); + + Assert.equal( + actualText, + `${400 * dpr * zoom} x ${400 * dpr * zoom}`, + "The selection size text is the same" + ); + } + ); +}); + +add_task(async function test_selectionSizeTestAtPoint5Zoom() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + const zoom = 0.5; + const dpr = browser.ownerGlobal.devicePixelRatio; + let helper = new ScreenshotsHelper(browser); + helper.zoomBrowser(zoom); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + await helper.dragOverlay(100, 100, 500, 500); + + let actualText = await helper.getOverlaySelectionSizeText(); + + Assert.equal( + actualText, + `${400 * dpr * zoom} x ${400 * dpr * zoom}`, + "The selection size text is the same" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/head.js b/browser/components/screenshots/tests/browser/head.js index a36e955830..762da5f866 100644 --- a/browser/components/screenshots/tests/browser/head.js +++ b/browser/components/screenshots/tests/browser/head.js @@ -159,23 +159,23 @@ class ScreenshotsHelper { }); } - waitForStateChange(newState) { - return SpecialPowers.spawn(this.browser, [newState], async state => { + waitForStateChange(newStateArr) { + return SpecialPowers.spawn(this.browser, [newStateArr], async stateArr => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); await ContentTaskUtils.waitForCondition(() => { - info(`got ${screenshotsChild.overlay.state}. expected ${state}`); - return screenshotsChild.overlay.state === state; - }, `Wait for overlay state to be ${state}`); + info(`got ${screenshotsChild.overlay.state}. expected ${stateArr}`); + return stateArr.includes(screenshotsChild.overlay.state); + }, `Wait for overlay state to be ${stateArr}`); return screenshotsChild.overlay.state; }); } async assertStateChange(newState) { - let currentState = await this.waitForStateChange(newState); + let currentState = await this.waitForStateChange([newState]); is( currentState, @@ -269,18 +269,13 @@ class ScreenshotsHelper { mouse.down(startX, startY); - await Promise.any([ - this.waitForStateChange("draggingReady"), - this.waitForStateChange("resizing"), - ]); + await this.waitForStateChange(["draggingReady", "resizing"]); Assert.ok(true, "The overlay is in the draggingReady or resizing state"); mouse.move(endX, endY); - await Promise.any([ - this.waitForStateChange("dragging"), - this.waitForStateChange("resizing"), - ]); + await this.waitForStateChange(["dragging", "resizing"]); + Assert.ok(true, "The overlay is in the dragging or resizing state"); // We intentionally turn off this a11y check, because the following mouse // event is emitted at the end of the dragging event. Its keyboard @@ -324,7 +319,6 @@ class ScreenshotsHelper { overlay.topRightMover.focus({ focusVisible: true }); break; } - screenshotsChild.overlay.highlightEl.focus(); for (let event of eventsArr) { EventUtils.synthesizeKey( @@ -354,7 +348,6 @@ class ScreenshotsHelper { } async scrollContentWindow(x, y) { - let promise = BrowserTestUtils.waitForContentEvent(this.browser, "scroll"); let contentDims = await this.getContentDimensions(); await ContentTask.spawn( this.browser, @@ -404,7 +397,6 @@ class ScreenshotsHelper { }, `Waiting for window to scroll to ${xPos}, ${yPos}`); } ); - await promise; } async waitForScrollTo(x, y) { @@ -521,6 +513,15 @@ class ScreenshotsHelper { }); } + getOverlaySelectionSizeText(elementId = "testPageElement") { + return ContentTask.spawn(this.browser, [elementId], async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild.overlay.selectionSize.textContent; + }); + } + async clickTestPageElement(elementId = "testPageElement") { let rect = await this.getTestPageElementRect(elementId); let dims = await this.getContentDimensions(); @@ -909,6 +910,21 @@ add_setup(async () => { ); let screenshotBtn = document.getElementById("screenshot-button"); Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar"); + + registerCleanupFunction(async () => { + info(`downloads panel should be visible: ${DownloadsPanel.isPanelShowing}`); + if (DownloadsPanel.isPanelShowing) { + let hiddenPromise = BrowserTestUtils.waitForEvent( + DownloadsPanel.panel, + "popuphidden" + ); + DownloadsPanel.hidePanel(); + await hiddenPromise; + info( + `downloads panel should not be visible: ${DownloadsPanel.isPanelShowing}` + ); + } + }); }); function getContentDevicePixelRatio(browser) { diff --git a/browser/components/search/.eslintrc.js b/browser/components/search/.eslintrc.js index 39079432e7..7224dc6eb7 100644 --- a/browser/components/search/.eslintrc.js +++ b/browser/components/search/.eslintrc.js @@ -5,8 +5,6 @@ "use strict"; module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], - rules: { "mozilla/var-only-at-top-level": "error", }, diff --git a/browser/components/search/DomainToCategoriesMap.worker.mjs b/browser/components/search/DomainToCategoriesMap.worker.mjs deleted file mode 100644 index 07dc52cfb8..0000000000 --- a/browser/components/search/DomainToCategoriesMap.worker.mjs +++ /dev/null @@ -1,101 +0,0 @@ -/* 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/. */ - -import { PromiseWorker } from "resource://gre/modules/workers/PromiseWorker.mjs"; - -/** - * Boilerplate to connect with the main thread PromiseWorker. - */ -const worker = new PromiseWorker.AbstractWorker(); -worker.dispatch = function (method, args = []) { - return agent[method](...args); -}; -worker.postMessage = function (message, ...transfers) { - self.postMessage(message, ...transfers); -}; -worker.close = function () { - self.close(); -}; - -self.addEventListener("message", msg => worker.handleMessage(msg)); -self.addEventListener("unhandledrejection", function (error) { - throw error.reason; -}); - -/** - * Stores and manages the Domain-to-Categories Map. - */ -class Agent { - /** - * @type {Map<string, Array<number>>} Hashes mapped to categories and values. - */ - #map = new Map(); - - /** - * Converts data from the array directly into a Map. - * - * @param {Array<ArrayBuffer>} fileContents Files - * @returns {boolean} Returns whether the Map contains results. - */ - populateMap(fileContents) { - this.#map.clear(); - - for (let fileContent of fileContents) { - let obj; - try { - obj = JSON.parse(new TextDecoder().decode(fileContent)); - } catch (ex) { - return false; - } - for (let objKey in obj) { - if (Object.hasOwn(obj, objKey)) { - this.#map.set(objKey, obj[objKey]); - } - } - } - return this.#map.size > 0; - } - - /** - * Retrieves scores for the hash from the map. - * - * @param {string} hash Key to look up in the map. - * @returns {Array<number>} - */ - getScores(hash) { - if (this.#map.has(hash)) { - return this.#map.get(hash); - } - return []; - } - - /** - * Empties the internal map. - * - * @returns {boolean} - */ - emptyMap() { - this.#map.clear(); - return true; - } - - /** - * Test only function to allow the map to contain information without - * having to go through Remote Settings. - * - * @param {object} obj The data to directly import into the Map. - * @returns {boolean} Whether the map contains values. - */ - overrideMapForTests(obj) { - this.#map.clear(); - for (let objKey in obj) { - if (Object.hasOwn(obj, objKey)) { - this.#map.set(objKey, obj[objKey]); - } - } - return this.#map.size > 0; - } -} - -const agent = new Agent(); diff --git a/browser/components/search/SearchSERPTelemetry.sys.mjs b/browser/components/search/SearchSERPTelemetry.sys.mjs index fa593be08c..2a9ed88db1 100644 --- a/browser/components/search/SearchSERPTelemetry.sys.mjs +++ b/browser/components/search/SearchSERPTelemetry.sys.mjs @@ -7,12 +7,12 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", Region: "resource://gre/modules/Region.sys.mjs", RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "gCryptoHash", () => { @@ -52,11 +52,15 @@ export const SEARCH_TELEMETRY_SHARED = { const impressionIdsWithoutEngagementsSet = new Set(); export const CATEGORIZATION_SETTINGS = { + STORE_SCHEMA: 1, + STORE_FILE: "domain_to_categories.sqlite", + STORE_NAME: "domain_to_categories", MAX_DOMAINS_TO_CATEGORIZE: 10, MINIMUM_SCORE: 0, STARTING_RANK: 2, IDLE_TIMEOUT_SECONDS: 60 * 60, WAKE_TIMEOUT_MS: 60 * 60 * 1000, + PING_SUBMISSION_THRESHOLD: 10, }; ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { @@ -83,15 +87,20 @@ XPCOMUtils.defineLazyPreferenceGetter( false, (aPreference, previousValue, newValue) => { if (newValue) { - SearchSERPDomainToCategoriesMap.init(); - SearchSERPCategorizationEventScheduler.init(); + SearchSERPCategorization.init(); } else { - SearchSERPDomainToCategoriesMap.uninit(); - SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorization.uninit({ deleteMap: true }); } } ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "activityLimit", + "telemetry.fog.test.activity_limit", + 120 +); + export const SearchSERPTelemetryUtils = { ACTIONS: { CLICKED: "clicked", @@ -380,7 +389,7 @@ class TelemetryHandler { * unit tests can set it to easy to test values. * * @param {Array} providerInfo - * See {@link https://searchfox.org/mozilla-central/search?q=search-telemetry-schema.json} + * See {@link https://searchfox.org/mozilla-central/search?q=search-telemetry-v2-schema.json} * for type information. */ overrideSearchTelemetryForTests(providerInfo) { @@ -1641,7 +1650,10 @@ class ContentHandler { !telemetryState.adImpressionsReported ) { for (let [componentType, data] of info.adImpressions.entries()) { - telemetryState.adsVisible += data.adsVisible; + // Not all ad impressions are sponsored. + if (AD_COMPONENTS.includes(componentType)) { + telemetryState.adsVisible += data.adsVisible; + } lazy.logConsole.debug("Counting ad:", { type: componentType, ...data }); Glean.serp.adImpression.record({ @@ -1772,6 +1784,8 @@ class ContentHandler { let item = this._findItemForBrowser(browser); let telemetryState = item.browserTelemetryStateMap.get(browser); if (lazy.serpEventTelemetryCategorization && telemetryState) { + lazy.logConsole.debug("Ad domains:", Array.from(info.adDomains)); + lazy.logConsole.debug("Non ad domains:", Array.from(info.nonAdDomains)); let result = await SearchSERPCategorization.maybeCategorizeSERP( info.nonAdDomains, info.adDomains, @@ -1789,6 +1803,7 @@ class ContentHandler { partner_code: impressionInfo.partnerCode, provider: impressionInfo.provider, tagged: impressionInfo.tagged, + is_shopping_page: impressionInfo.isShoppingPage, num_ads_clicked: telemetryState.adsClicked, num_ads_visible: telemetryState.adsVisible, }); @@ -1843,6 +1858,22 @@ class ContentHandler { * Categorizes SERPs. */ class SERPCategorizer { + async init() { + if (lazy.serpEventTelemetryCategorization) { + lazy.logConsole.debug("Initialize SERP categorizer."); + await SearchSERPDomainToCategoriesMap.init(); + SearchSERPCategorizationEventScheduler.init(); + SERPCategorizationRecorder.init(); + } + } + + async uninit({ deleteMap = false } = {}) { + lazy.logConsole.debug("Uninit SERP categorizer."); + await SearchSERPDomainToCategoriesMap.uninit(deleteMap); + SearchSERPCategorizationEventScheduler.uninit(); + SERPCategorizationRecorder.uninit(); + } + /** * Categorizes domains extracted from SERPs. Note that we don't process * domains if the domain-to-categories map is empty (if the client couldn't @@ -1999,12 +2030,8 @@ class CategorizationEventScheduler { */ #mostRecentMs = null; - constructor() { - this.init(); - } - init() { - if (!lazy.serpEventTelemetryCategorization || this.#init) { + if (this.#init) { return; } @@ -2114,6 +2141,61 @@ class CategorizationEventScheduler { * Handles reporting SERP categorization telemetry to Glean. */ class CategorizationRecorder { + #init = false; + + // The number of SERP categorizations that have been recorded but not yet + // reported in a Glean ping. + #serpCategorizationsCount = 0; + + // When the user started interacting with the SERP. + #userInteractionStartTime = null; + + async init() { + if (this.#init) { + return; + } + + Services.obs.addObserver(this, "user-interaction-active"); + Services.obs.addObserver(this, "user-interaction-inactive"); + this.#init = true; + this.submitPing("startup"); + Services.obs.notifyObservers(null, "categorization-recorder-init"); + } + + uninit() { + if (this.#init) { + Services.obs.removeObserver(this, "user-interaction-active"); + Services.obs.removeObserver(this, "user-interaction-inactive"); + this.#resetCategorizationRecorderData(); + this.#init = false; + } + } + + observe(subject, topic, _data) { + switch (topic) { + case "user-interaction-active": { + // If the user is already active, we don't want to overwrite the start + // time. + if (this.#userInteractionStartTime == null) { + this.#userInteractionStartTime = Date.now(); + } + break; + } + case "user-interaction-inactive": { + let currentTime = Date.now(); + let activityLimitInMs = lazy.activityLimit * 1000; + if ( + this.#userInteractionStartTime && + currentTime - this.#userInteractionStartTime >= activityLimitInMs + ) { + this.submitPing("inactivity"); + } + this.#userInteractionStartTime = null; + break; + } + } + } + /** * Helper function for recording the SERP categorization event. * @@ -2125,7 +2207,37 @@ class CategorizationRecorder { "Reporting the following categorization result:", resultToReport ); - // TODO: Bug 1868476 - Report result to Glean. + Glean.serp.categorization.record(resultToReport); + + this.#serpCategorizationsCount++; + if ( + this.#serpCategorizationsCount >= + CATEGORIZATION_SETTINGS.PING_SUBMISSION_THRESHOLD + ) { + this.submitPing("threshold_reached"); + this.#serpCategorizationsCount = 0; + } + } + + submitPing(reason) { + lazy.logConsole.debug("Submitting SERP categorization ping:", reason); + GleanPings.serpCategorization.submit(reason); + } + + /** + * Tests are able to clear telemetry on demand. When that happens, we need to + * ensure we're doing to the same here or else the internal count in tests + * will be inaccurate. + */ + testReset() { + if (Cu.isInAutomation) { + this.#resetCategorizationRecorderData(); + } + } + + #resetCategorizationRecorderData() { + this.#serpCategorizationsCount = 0; + this.#userInteractionStartTime = null; } } @@ -2144,10 +2256,8 @@ class CategorizationRecorder { */ /** - * Maps domain to categories, with its data synced using Remote Settings. The - * data is downloaded from Remote Settings and stored in a map in a worker - * thread to avoid processing the data from the attachments from occupying - * the main thread. + * Maps domain to categories. Data is downloaded from Remote Settings and + * stored inside DomainToCategoriesStore. */ class DomainToCategoriesMap { /** @@ -2195,40 +2305,63 @@ class DomainToCategoriesMap { #downloadRetries = 0; /** - * Whether the mappings are empty. - */ - #empty = true; - - /** - * @type {BasePromiseWorker|null} Worker used to access the raw domain - * to categories map data. + * A reference to the data store. + * + * @type {DomainToCategoriesStore | null} */ - #worker = null; + #store = null; /** * Runs at application startup with startup idle tasks. If the SERP * categorization preference is enabled, it creates a Remote Settings - * client to listen to updates, and populates the map. + * client to listen to updates, and populates the store. */ async init() { - if (!lazy.serpEventTelemetryCategorization || this.#init) { + if (this.#init) { return; } lazy.logConsole.debug("Initializing domain-to-categories map."); - this.#worker = new lazy.BasePromiseWorker( - "resource:///modules/DomainToCategoriesMap.worker.mjs", - { type: "module" } - ); - await this.#setupClientAndMap(); + + // Set early to allow un-init from an initialization. this.#init = true; + + try { + await this.#setupClientAndStore(); + } catch (ex) { + lazy.logConsole.error(ex); + await this.uninit(); + return; + } + + // If we don't have a client and store, it likely means an un-init process + // started during the initialization process. + if (this.#client && this.#store) { + lazy.logConsole.debug("Initialized domain-to-categories map."); + Services.obs.notifyObservers(null, "domain-to-categories-map-init"); + } } - uninit() { + async uninit(shouldDeleteStore) { if (this.#init) { lazy.logConsole.debug("Un-initializing domain-to-categories map."); - this.#clearClientAndWorker(); + this.#clearClient(); this.#cancelAndNullifyTimer(); + + if (this.#store) { + if (shouldDeleteStore) { + try { + await this.#store.dropData(); + } catch (ex) { + lazy.logConsole.error(ex); + } + } + await this.#store.uninit(); + this.#store = null; + } + + lazy.logConsole.debug("Un-initialized domain-to-categories map."); this.#init = false; + Services.obs.notifyObservers(null, "domain-to-categories-map-uninit"); } } @@ -2241,14 +2374,14 @@ class DomainToCategoriesMap { * for the domain is available, return an empty array. */ async get(domain) { - if (this.empty) { + if (!this.#store || this.#store.empty || !this.#store.ready) { return []; } lazy.gCryptoHash.init(lazy.gCryptoHash.SHA256); let bytes = new TextEncoder().encode(domain); lazy.gCryptoHash.update(bytes, domain.length); let hash = lazy.gCryptoHash.finish(true); - let rawValues = await this.#worker.post("getScores", [hash]); + let rawValues = await this.#store.getCategories(hash); if (rawValues?.length) { let output = []; // Transform data into a more readable format. @@ -2275,12 +2408,15 @@ class DomainToCategoriesMap { } /** - * Whether the map is empty of data. + * Whether the store is empty of data. * * @returns {boolean} */ get empty() { - return this.#empty; + if (!this.#store) { + return true; + } + return this.#store.empty; } /** @@ -2290,15 +2426,26 @@ class DomainToCategoriesMap { * @param {object} domainToCategoriesMap * An object where the key is a hashed domain and the value is an array * containing an arbitrary number of DomainCategoryScores. + * @param {number} version + * The version number for the store. */ - async overrideMapForTests(domainToCategoriesMap) { - let hasResults = await this.#worker.post("overrideMapForTests", [ - domainToCategoriesMap, - ]); - this.#empty = !hasResults; + async overrideMapForTests(domainToCategoriesMap, version = 1) { + if (Cu.isInAutomation || Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + await this.#store.init(); + await this.#store.dropData(); + await this.#store.insertObject(domainToCategoriesMap, version); + } } - async #setupClientAndMap() { + /** + * Connect with Remote Settings and retrieve the records associated with + * categorization. Then, check if the records match the store version. If + * no records exist, return early. If records exist but the version stored + * on the records differ from the store version, then attempt to + * empty the store and fill it with data from downloaded attachments. Only + * reuse the store if the version in each record matches the store. + */ + async #setupClientAndStore() { if (this.#client && !this.empty) { return; } @@ -2308,11 +2455,33 @@ class DomainToCategoriesMap { this.#onSettingsSync = event => this.#sync(event.data); this.#client.on("sync", this.#onSettingsSync); + this.#store = new DomainToCategoriesStore(); + await this.#store.init(); + let records = await this.#client.get(); - await this.#clearAndPopulateMap(records); + // Even though records don't exist, this is still technically initialized + // since the next sync from Remote Settings will populate the store with + // records. + if (!records.length) { + lazy.logConsole.debug("No records found for domain-to-categories map."); + return; + } + + this.#version = this.#retrieveLatestVersion(records); + let storeVersion = await this.#store.getVersion(); + if (storeVersion == this.#version && !this.#store.empty) { + lazy.logConsole.debug("Reuse existing domain-to-categories map."); + Services.obs.notifyObservers( + null, + "domain-to-categories-map-update-complete" + ); + return; + } + + await this.#clearAndPopulateStore(records); } - #clearClientAndWorker() { + #clearClient() { if (this.#client) { lazy.logConsole.debug("Removing Remote Settings client."); this.#client.off("sync", this.#onSettingsSync); @@ -2320,17 +2489,6 @@ class DomainToCategoriesMap { this.#onSettingsSync = null; this.#downloadRetries = 0; } - - if (!this.#empty) { - lazy.logConsole.debug("Clearing domain-to-categories map."); - this.#empty = true; - this.#version = null; - } - - if (this.#worker) { - this.#worker.terminate(); - this.#worker = null; - } } /** @@ -2377,27 +2535,50 @@ class DomainToCategoriesMap { // again in case there's a new download error. this.#downloadRetries = 0; - this.#clearAndPopulateMap(data?.current); + try { + await this.#clearAndPopulateStore(data?.current); + } catch (ex) { + lazy.logConsole.error("Error populating map: ", ex); + await this.uninit(); + } } /** - * Clear the existing map and populate it with attachments found in the + * Clear the existing store and populate it with attachments found in the * records. If no attachments are found, or no record containing an * attachment contained the latest version, then nothing will change. * * @param {Array<DomainToCategoriesRecord>} records * The records containing attachments. - * + * @throws {Error} + * Will throw if it was not able to drop the store data, or it was unable + * to insert data into the store. */ - async #clearAndPopulateMap(records) { - // Empty map so that if there are errors in the download process, callers - // querying the map won't use information we know is already outdated. - await this.#worker.post("emptyMap"); + async #clearAndPopulateStore(records) { + // If we don't have a handle to a store, it would mean that it was removed + // during an uninitialization process. + if (!this.#store) { + lazy.logConsole.debug( + "Could not populate store because no store was available." + ); + return; + } + + if (!this.#store.ready) { + lazy.logConsole.debug( + "Could not populate store because it was not ready." + ); + return; + } + + // Empty table so that if there are errors in the download process, callers + // querying the map won't use information we know is probably outdated. + await this.#store.dropData(); - this.#empty = true; this.#version = null; this.#cancelAndNullifyTimer(); + // A collection with no records is still a valid init state. if (!records?.length) { lazy.logConsole.debug("No records found for domain-to-categories map."); return; @@ -2418,41 +2599,24 @@ class DomainToCategoriesMap { fileContents.push(result.buffer); } ChromeUtils.addProfilerMarker( - "SearchSERPTelemetry.#clearAndPopulateMap", + "SearchSERPTelemetry.#clearAndPopulateStore", start, "Download attachments." ); - // Attachments should have a version number. this.#version = this.#retrieveLatestVersion(records); - if (!this.#version) { lazy.logConsole.debug("Could not find a version number for any record."); return; } - Services.tm.idleDispatchToMainThread(async () => { - start = Cu.now(); - let hasResults; - try { - hasResults = await this.#worker.post("populateMap", [fileContents]); - } catch (ex) { - console.error(ex); - } + await this.#store.insertFileContents(fileContents, this.#version); - this.#empty = !hasResults; - - ChromeUtils.addProfilerMarker( - "SearchSERPTelemetry.#clearAndPopulateMap", - start, - "Convert contents to JSON." - ); - lazy.logConsole.debug("Updated domain-to-categories map."); - Services.obs.notifyObservers( - null, - "domain-to-categories-map-update-complete" - ); - }); + lazy.logConsole.debug("Finished updating domain-to-categories store."); + Services.obs.notifyObservers( + null, + "domain-to-categories-map-update-complete" + ); } #cancelAndNullifyTimer() { @@ -2466,7 +2630,8 @@ class DomainToCategoriesMap { #createTimerToPopulateMap() { if ( this.#downloadRetries >= - TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxTriesPerSession + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxTriesPerSession || + !this.#client ) { return; } @@ -2486,7 +2651,12 @@ class DomainToCategoriesMap { async () => { this.#downloadRetries += 1; let records = await this.#client.get(); - this.#clearAndPopulateMap(records); + try { + await this.#clearAndPopulateStore(records); + } catch (ex) { + lazy.logConsole.error("Error populating store: ", ex); + await this.uninit(); + } }, delay, Ci.nsITimer.TYPE_ONE_SHOT @@ -2494,6 +2664,514 @@ class DomainToCategoriesMap { } } +/** + * Handles the storage of data containing domains to categories. + */ +export class DomainToCategoriesStore { + #init = false; + + /** + * The connection to the store. + * + * @type {object | null} + */ + #connection = null; + + /** + * Reference for the shutdown blocker in case we need to remove it before + * shutdown. + * + * @type {Function | null} + */ + #asyncShutdownBlocker = null; + + /** + * Whether the store is empty of data. + * + * @type {boolean} + */ + #empty = true; + + /** + * For a particular subset of errors, we'll attempt to rebuild the database + * from scratch. + */ + #rebuildableErrors = ["NS_ERROR_FILE_CORRUPTED"]; + + /** + * Initializes the store. If the store is initialized it should have cached + * a connection to the store and ensured the store exists. + */ + async init() { + if (this.#init) { + return; + } + lazy.logConsole.debug("Initializing domain-to-categories store."); + + // Attempts to cache a connection to the store. + // If a failure occured, try to re-build the store. + let rebuiltStore = false; + try { + await this.#initConnection(); + } catch (ex1) { + lazy.logConsole.error(`Error initializing a connection: ${ex1}`); + if (this.#rebuildableErrors.includes(ex1.name)) { + try { + await this.#rebuildStore(); + } catch (ex2) { + await this.#closeConnection(); + lazy.logConsole.error(`Could not rebuild store: ${ex2}`); + return; + } + rebuiltStore = true; + } + } + + // If we don't have a connection, bail because the browser could be + // shutting down ASAP, or re-creating the store is impossible. + if (!this.#connection) { + lazy.logConsole.debug( + "Bailing from DomainToCategoriesStore.init because connection doesn't exist." + ); + return; + } + + // If we weren't forced to re-build the store, we only have the connection. + // We want to ensure the store exists so calls to public methods can pass + // without throwing errors due to the absence of the store. + if (!rebuiltStore) { + try { + await this.#initSchema(); + } catch (ex) { + lazy.logConsole.error(`Error trying to create store: ${ex}`); + await this.#closeConnection(); + return; + } + } + + lazy.logConsole.debug("Initialized domain-to-categories store."); + this.#init = true; + } + + async uninit() { + if (this.#init) { + lazy.logConsole.debug("Un-initializing domain-to-categories store."); + await this.#closeConnection(); + this.#asyncShutdownBlocker = null; + lazy.logConsole.debug("Un-initialized domain-to-categories store."); + } + } + + /** + * Whether the store has an open connection to the physical store. + * + * @returns {boolean} + */ + get ready() { + return this.#init; + } + + /** + * Whether the store is devoid of data. + * + * @returns {boolean} + */ + get empty() { + return this.#empty; + } + + /** + * Clears information in the store. If dropping data encountered a failure, + * try to delete the file containing the store and re-create it. + * + * @throws {Error} Will throw if it was unable to clear information from the + * store. + */ + async dropData() { + if (!this.#connection) { + return; + } + let tableExists = await this.#connection.tableExists( + CATEGORIZATION_SETTINGS.STORE_NAME + ); + if (tableExists) { + lazy.logConsole.debug("Drop domain_to_categories."); + // This can fail if the permissions of the store are read-only. + await this.#connection.executeTransaction(async () => { + await this.#connection.execute(`DROP TABLE domain_to_categories`); + const createDomainToCategoriesTable = ` + CREATE TABLE IF NOT EXISTS + domain_to_categories ( + string_id + TEXT PRIMARY KEY NOT NULL, + categories + TEXT + ); + `; + await this.#connection.execute(createDomainToCategoriesTable); + await this.#connection.execute(`DELETE FROM moz_meta`); + await this.#connection.executeCached( + ` + INSERT INTO + moz_meta (key, value) + VALUES + (:key, :value) + ON CONFLICT DO UPDATE SET + value = :value + `, + { key: "version", value: 0 } + ); + }); + + this.#empty = true; + } + } + + /** + * Given file contents, try moving them into the store. If a failure occurs, + * it will attempt to drop existing data to ensure callers aren't accessing + * a partially filled store. + * + * @param {Array<ArrayBuffer>} fileContents + * Contents to convert. + * @param {number} version + * The version for the store. + * @throws {Error} + * Will throw if the insertion failed and dropData was unable to run + * successfully. + */ + async insertFileContents(fileContents, version) { + if (!this.#init || !fileContents?.length || !version) { + return; + } + + try { + await this.#insert(fileContents, version); + } catch (ex) { + lazy.logConsole.error(`Could not insert file contents: ${ex}`); + await this.dropData(); + } + } + + /** + * Convenience function to make it trivial to insert Javascript objects into + * the store. This avoids having to set up the collection in Remote Settings. + * + * @param {object} domainToCategoriesMap + * An object whose keys should be hashed domains with values containing + * an array of integers. + * @param {number} version + * The version for the store. + * @returns {boolean} + * Whether the operation was successful. + */ + async insertObject(domainToCategoriesMap, version) { + if (!Cu.isInAutomation || !this.#init) { + return false; + } + let buffer = new TextEncoder().encode( + JSON.stringify(domainToCategoriesMap) + ).buffer; + await this.insertFileContents([buffer], version); + return true; + } + + /** + * Retrieves domains mapped to the key. + * + * @param {string} key + * The value to lookup in the store. + * @returns {Array<number>} + * An array of numbers corresponding to the category and score. If the key + * does not exist in the store or the store is having issues retrieving the + * value, returns an empty array. + */ + async getCategories(key) { + if (!this.#init) { + return []; + } + + let rows; + try { + rows = await this.#connection.executeCached( + ` + SELECT + categories + FROM + domain_to_categories + WHERE + string_id = :key + `, + { + key, + } + ); + } catch (ex) { + lazy.logConsole.error(`Could not retrieve from the store: ${ex}`); + return []; + } + + if (!rows.length) { + return []; + } + return JSON.parse(rows[0].getResultByName("categories")) ?? []; + } + + /** + * Retrieves the version number of the store. + * + * @returns {number} + * The version number. Returns 0 if the version was never set or if there + * was an issue accessing the version number. + */ + async getVersion() { + if (this.#connection) { + let rows; + try { + rows = await this.#connection.executeCached( + ` + SELECT + value + FROM + moz_meta + WHERE + key = "version" + ` + ); + } catch (ex) { + lazy.logConsole.error(`Could not retrieve version of the store: ${ex}`); + return 0; + } + if (rows.length) { + return parseInt(rows[0].getResultByName("value")) ?? 0; + } + } + return 0; + } + + /** + * Test only function allowing tests to delete the store. + */ + async testDelete() { + if (Cu.isInAutomation) { + await this.#closeConnection(); + await this.#delete(); + } + } + + /** + * If a connection is available, close it and remove shutdown blockers. + */ + async #closeConnection() { + this.#init = false; + this.#empty = true; + if (this.#asyncShutdownBlocker) { + lazy.Sqlite.shutdown.removeBlocker(this.#asyncShutdownBlocker); + this.#asyncShutdownBlocker = null; + } + + if (this.#connection) { + lazy.logConsole.debug("Closing connection."); + // An error could occur while closing the connection. We suppress the + // error since it is not a critical part of the browser. + try { + await this.#connection.close(); + } catch (ex) { + lazy.logConsole.error(ex); + } + this.#connection = null; + } + } + + /** + * Initialize the schema for the store. + * + * @throws {Error} + * Will throw if a permissions error prevents creating the store. + */ + async #initSchema() { + if (!this.#connection) { + return; + } + lazy.logConsole.debug("Create store."); + // Creation can fail if the store is read only. + await this.#connection.executeTransaction(async () => { + // Let outer try block handle the exception. + const createDomainToCategoriesTable = ` + CREATE TABLE IF NOT EXISTS + domain_to_categories ( + string_id + TEXT PRIMARY KEY NOT NULL, + categories + TEXT + ) WITHOUT ROWID; + `; + await this.#connection.execute(createDomainToCategoriesTable); + const createMetaTable = ` + CREATE TABLE IF NOT EXISTS + moz_meta ( + key + TEXT PRIMARY KEY NOT NULL, + value + INTEGER + ) WITHOUT ROWID; + `; + await this.#connection.execute(createMetaTable); + await this.#connection.setSchemaVersion( + CATEGORIZATION_SETTINGS.STORE_SCHEMA + ); + }); + + let rows = await this.#connection.executeCached( + "SELECT count(*) = 0 FROM domain_to_categories" + ); + this.#empty = !!rows[0].getResultByIndex(0); + } + + /** + * Attempt to delete the store. + * + * @throws {Error} + * Will throw if the permissions for the file prevent its deletion. + */ + async #delete() { + lazy.logConsole.debug("Attempt to delete the store."); + try { + await IOUtils.remove( + PathUtils.join( + PathUtils.profileDir, + CATEGORIZATION_SETTINGS.STORE_FILE + ), + { ignoreAbsent: true } + ); + } catch (ex) { + lazy.logConsole.error(ex); + } + this.#empty = true; + lazy.logConsole.debug("Store was deleted."); + } + + /** + * Tries to establish a connection to the store. + * + * @throws {Error} + * Will throw if there was an issue establishing a connection or adding + * adding a shutdown blocker. + */ + async #initConnection() { + if (this.#connection) { + return; + } + + // This could fail if the store is corrupted. + this.#connection = await lazy.Sqlite.openConnection({ + path: PathUtils.join( + PathUtils.profileDir, + CATEGORIZATION_SETTINGS.STORE_FILE + ), + }); + + await this.#connection.execute("PRAGMA journal_mode = TRUNCATE"); + + this.#asyncShutdownBlocker = async () => { + await this.#connection.close(); + this.#connection = null; + }; + + // This could fail if we're adding it during shutdown. In this case, + // don't throw but close the connection. + try { + lazy.Sqlite.shutdown.addBlocker( + "SearchSERPTelemetry:DomainToCategoriesSqlite closing", + this.#asyncShutdownBlocker + ); + } catch (ex) { + lazy.logConsole.error(ex); + await this.#closeConnection(); + } + } + + /** + * Inserts into the store. + * + * @param {Array<ArrayBuffer>} fileContents + * The data that should be converted and inserted into the store. + * @param {number} version + * The version number that should be inserted into the store. + * @throws {Error} + * Will throw if a connection is not present, if the store is not + * able to be updated (permissions error, corrupted file), or there is + * something wrong with the file contents. + */ + async #insert(fileContents, version) { + let start = Cu.now(); + await this.#connection.executeTransaction(async () => { + lazy.logConsole.debug("Insert into domain_to_categories table."); + for (let fileContent of fileContents) { + await this.#connection.executeCached( + ` + INSERT INTO + domain_to_categories (string_id, categories) + SELECT + json_each.key AS string_id, + json_each.value AS categories + FROM + json_each(json(:obj)) + `, + { + obj: new TextDecoder().decode(fileContent), + } + ); + } + // Once the insertions have successfully completed, update the version. + await this.#connection.executeCached( + ` + INSERT INTO + moz_meta (key, value) + VALUES + (:key, :value) + ON CONFLICT DO UPDATE SET + value = :value + `, + { key: "version", value: version } + ); + }); + ChromeUtils.addProfilerMarker( + "DomainToCategoriesSqlite.#insert", + start, + "Move file contents into table." + ); + + if (fileContents?.length) { + this.#empty = false; + } + } + + /** + * Deletes and re-build's the store. Used in cases where we encounter a + * failure and we want to try fixing the error by starting with an + * entirely fresh store. + * + * @throws {Error} + * Will throw if a connection could not be established, if it was + * unable to delete the store, or it was unable to build a new store. + */ + async #rebuildStore() { + lazy.logConsole.debug("Try rebuilding store."); + // Step 1. Close all connections. + await this.#closeConnection(); + + // Step 2. Delete the existing store. + await this.#delete(); + + // Step 3. Re-establish the connection. + await this.#initConnection(); + + // Step 4. If a connection exists, try creating the store. + await this.#initSchema(); + } +} + function randomInteger(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } diff --git a/browser/components/search/metrics.yaml b/browser/components/search/metrics.yaml index 12fd44a0e2..c7636b9d04 100644 --- a/browser/components/search/metrics.yaml +++ b/browser/components/search/metrics.yaml @@ -331,6 +331,110 @@ serp: - fx-search-telemetry@mozilla.com expires: never + categorization: + type: event + description: > + A high-level categorization of a SERP (a best guess as to its topic), + using buckets such as "sports" or "travel". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1868476 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1869064 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887686 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1868476 + data_sensitivity: + - stored_content + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + extra_keys: + sponsored_category: + description: > + An index corresponding to a broad category for the SERP, derived from + sponsored domains. + type: quantity + sponsored_num_domains: + description: > + The total number of sponsored domains used in the categorization + process for the SERP. + type: quantity + sponsored_num_unknown: + description: > + The count of sponsored domains extracted from the SERP that are not + found in the domain-to-categories mapping. + type: quantity + sponsored_num_inconclusive: + description: > + The count of sponsored domains extracted from the SERP that are found + in the domain-to-categories mapping but are deemed inconclusive. + type: quantity + organic_category: + description: > + An index corresponding to a broad category for the SERP, derived from + organic domains. + type: quantity + organic_num_domains: + description: > + The total number of organic domains used in the categorization + process for the SERP. + type: quantity + organic_num_unknown: + description: > + The count of organic domains extracted from the SERP that are not + found in the domain-to-categories mapping. + type: quantity + organic_num_inconclusive: + description: > + The count of organic domains extracted from the SERP that are found + in the domain-to-categories mapping but are deemed inconclusive. + type: quantity + region: + description: > + A two-letter country code indicating where the SERP was loaded. + type: string + channel: + description: > + The type of update channel, for example: “nightly”, “beta”, “release”. + type: string + provider: + description: > + The name of the provider. + type: string + tagged: + description: > + Whether the search is tagged (true) or organic (false). + type: boolean + partner_code: + description: > + Any partner_code parsing in the URL or an empty string if not + available. + type: string + app_version: + description: > + The Firefox major version used, for example: 126. + type: quantity + mappings_version: + description: > + Version number for the Remote Settings attachments used to generate + the domain-to-categories map used in the SERP categorization process. + type: quantity + is_shopping_page: + description: > + Indicates if the page is a shopping page. + type: boolean + num_ads_visible: + description: > + Number of ads visible on the page at the time of categorizing the + page. + type: quantity + num_ads_clicked: + description: > + Number of ads clicked on the page. + type: quantity + send_in_pings: + - serp-categorization + search_with: reporting_url: type: url diff --git a/browser/components/search/moz.build b/browser/components/search/moz.build index 0289f32979..ff49a259ed 100644 --- a/browser/components/search/moz.build +++ b/browser/components/search/moz.build @@ -6,7 +6,6 @@ EXTRA_JS_MODULES += [ "BrowserSearchTelemetry.sys.mjs", - "DomainToCategoriesMap.worker.mjs", "SearchOneOffs.sys.mjs", "SearchSERPTelemetry.sys.mjs", "SearchUIUtils.sys.mjs", @@ -18,7 +17,10 @@ BROWSER_CHROME_MANIFESTS += [ "test/browser/telemetry/browser.toml", ] -MARIONETTE_MANIFESTS += ["test/marionette/manifest.toml"] +MARIONETTE_MANIFESTS += [ + "test/marionette/manifest.toml", + "test/marionette/telemetry/manifest.toml", +] XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] diff --git a/browser/components/search/schema/search-telemetry-schema.json b/browser/components/search/schema/search-telemetry-v2-schema.json index 50b6e124fc..50b6e124fc 100644 --- a/browser/components/search/schema/search-telemetry-schema.json +++ b/browser/components/search/schema/search-telemetry-v2-schema.json diff --git a/browser/components/search/schema/search-telemetry-ui-schema.json b/browser/components/search/schema/search-telemetry-v2-ui-schema.json index 781da5a626..749063db72 100644 --- a/browser/components/search/schema/search-telemetry-ui-schema.json +++ b/browser/components/search/schema/search-telemetry-v2-ui-schema.json @@ -11,10 +11,12 @@ "organicCodes", "followOnParamNames", "followOnCookies", + "ignoreLinkRegexps", "extraAdServersRegexps", "adServerAttributes", "components", "nonAdsLinkRegexps", + "nonAdsLinkQueryParamNames", "shoppingTab", "domainExtraction", "isSPA", diff --git a/browser/components/search/test/browser/telemetry/browser.toml b/browser/components/search/test/browser/telemetry/browser.toml index 660fc4eae2..5e42a9187d 100644 --- a/browser/components/search/test/browser/telemetry/browser.toml +++ b/browser/components/search/test/browser/telemetry/browser.toml @@ -50,6 +50,15 @@ support-files = ["searchTelemetryDomainCategorizationReporting.html"] ["browser_search_telemetry_domain_categorization_extraction.js"] support-files = ["searchTelemetryDomainExtraction.html"] +["browser_search_telemetry_domain_categorization_no_sponsored_values.js"] +support-files = ["searchTelemetryDomainCategorizationReportingWithoutAds.html"] + +["browser_search_telemetry_domain_categorization_ping_submission.js"] +support-files = [ + "searchTelemetryDomainCategorizationReporting.html", + "searchTelemetryDomainExtraction.html", +] + ["browser_search_telemetry_domain_categorization_region.js"] support-files = ["searchTelemetryDomainCategorizationReporting.html"] @@ -103,13 +112,6 @@ support-files = [ "searchTelemetryAd_searchbox_with_content.html^headers^", ] -["browser_search_telemetry_engagement_non_ad.js"] -support-files = [ - "searchTelemetryAd_searchbox_with_content.html", - "searchTelemetryAd_searchbox_with_content.html^headers^", - "serp.css", -] - ["browser_search_telemetry_engagement_nonAdsLinkQueryParamNames.js"] support-files = [ "searchTelemetryAd_searchbox_with_redirecting_links.html", @@ -118,6 +120,13 @@ support-files = [ "serp.css", ] +["browser_search_telemetry_engagement_non_ad.js"] +support-files = [ + "searchTelemetryAd_searchbox_with_content.html", + "searchTelemetryAd_searchbox_with_content.html^headers^", + "serp.css", +] + ["browser_search_telemetry_engagement_query_params.js"] support-files = [ "searchTelemetryAd_components_query_parameters.html", diff --git a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js index e73a9601d4..8e9db64fae 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js +++ b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js @@ -74,11 +74,14 @@ add_setup(async function () { let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await insertRecordIntoCollectionAndSync(); // If the categorization preference is enabled, we should also wait for the // sync event to update the domain to categories map. if (lazy.serpEventsCategorizationEnabled) { - await waitForDomainToCategoriesUpdate(); + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await promise; + } else { + await insertRecordIntoCollectionAndSync(); } registerCleanupFunction(async () => { @@ -99,6 +102,11 @@ add_task(async function test_enable_experiment_when_pref_is_not_enabled() { // the default branch, and not overwrite the user branch. prefBranch.setBoolPref(TELEMETRY_PREF, false); + // If it was true, we should wait until the map is fully un-inited. + if (originalPrefValue) { + await waitForDomainToCategoriesUninit(); + } + Assert.equal( lazy.serpEventsCategorizationEnabled, false, @@ -152,6 +160,7 @@ add_task(async function test_enable_experiment_when_pref_is_not_enabled() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -160,6 +169,7 @@ add_task(async function test_enable_experiment_when_pref_is_not_enabled() { info("End experiment."); await doExperimentCleanup(); + await waitForDomainToCategoriesUninit(); Assert.equal( lazy.serpEventsCategorizationEnabled, @@ -179,6 +189,7 @@ add_task(async function test_enable_experiment_when_pref_is_not_enabled() { await new Promise(resolve => setTimeout(resolve, 1500)); BrowserTestUtils.removeTab(tab); + // We should not record telemetry if the experiment is un-enrolled. assertCategorizationValues([]); // Clean up. diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js index 246caf6f47..daccbf0c93 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js @@ -71,6 +71,16 @@ add_setup(async function () { await promise; registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } SearchSERPTelemetry.overrideSearchTelemetryForTests(); resetTelemetry(); }); @@ -103,6 +113,7 @@ add_task(async function test_load_serp_and_categorize() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -143,6 +154,7 @@ add_task(async function test_load_serp_and_categorize_and_click_organic() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -181,6 +193,7 @@ add_task(async function test_load_serp_and_categorize_and_click_sponsored() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "1", num_ads_visible: "2", }, diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js index b8dd85da97..9bd215f697 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js @@ -82,11 +82,20 @@ add_setup(async function () { await db.clear(); - // Set the state of the pref to false so that tests toggle the preference, - // triggering the map to be updated. - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetryCategorization.enabled", false]], - }); + // If the pref is by default on, disable it as the following tests toggle + // the preference to check what happens when the preference is off and the + // preference is turned on. + if ( + Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + let promise = waitForDomainToCategoriesUninit(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", false]], + }); + await promise; + } let defaultDownloadSettings = { ...TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS, @@ -104,6 +113,16 @@ add_setup(async function () { TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 0; registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be initialized. + await SpecialPowers.popPrefEnv(); + if ( + Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesInit(); + } SearchSERPTelemetry.overrideSearchTelemetryForTests(); resetTelemetry(); TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS = { @@ -159,6 +178,7 @@ add_task(async function test_download_after_failure() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_visible: "2", num_ads_clicked: "0", }, @@ -166,6 +186,7 @@ add_task(async function test_download_after_failure() { // Clean up. await SpecialPowers.popPrefEnv(); + await waitForDomainToCategoriesUninit(); await resetCategorizationCollection(record); }); @@ -214,6 +235,7 @@ add_task(async function test_download_after_multiple_failures() { // Clean up. await SpecialPowers.popPrefEnv(); + await waitForDomainToCategoriesUninit(); await resetCategorizationCollection(record); }); @@ -245,6 +267,7 @@ add_task(async function test_cancel_download_timer() { }); await SpecialPowers.popPrefEnv(); await observeCancel; + await waitForDomainToCategoriesUninit(); // To ensure we don't attempt another download, wait a bit over how long the // the download error should take. @@ -263,7 +286,6 @@ add_task(async function test_cancel_download_timer() { Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty"); // Clean up. - await SpecialPowers.popPrefEnv(); await resetCategorizationCollection(record); }); @@ -310,6 +332,7 @@ add_task(async function test_download_adjust() { // Clean up. await SpecialPowers.popPrefEnv(); + await waitForDomainToCategoriesUninit(); await resetCategorizationCollection(record); TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = TIMEOUT_IN_MS; TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 0; diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js index e653be6c48..2d13b147a2 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js @@ -362,19 +362,57 @@ const TESTS = [ ], expectedDomains: ["organic.com"], }, + { + title: "Bing organic result with a path in the URL.", + extractorInfos: [ + { + selectors: "#test26 #b_results .b_algo .b_attribution cite", + method: "textContent", + }, + ], + expectedDomains: ["organic.com"], + }, + { + title: "Bing organic result with a path and query param in the URL.", + extractorInfos: [ + { + selectors: "#test27 #b_results .b_algo .b_attribution cite", + method: "textContent", + }, + ], + expectedDomains: ["organic.com"], + }, + { + title: + "Bing organic result with a path in the URL, but protocol appears in separate HTML element.", + extractorInfos: [ + { + selectors: "#test28 #b_results .b_algo .b_attribution cite", + method: "textContent", + }, + ], + expectedDomains: ["wikipedia.org"], + }, ]; add_setup(async function () { await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.search.serpEventTelemetry.enabled", true], - ["browser.search.serpEventTelemetryCategorization.enabled", true], - ], + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], }); await SearchSERPTelemetry.init(); registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } resetTelemetry(); }); }); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_no_sponsored_values.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_no_sponsored_values.js new file mode 100644 index 0000000000..2375cad82a --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_no_sponsored_values.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * Checks reporting of pages without ads is accurate. + */ + +ChromeUtils.defineESModuleGetters(this, { + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + shoppingTab: { + selector: "#shopping", + }, + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task( + async function test_load_serp_without_sponsored_links_and_categorize() { + resetTelemetry(); + + let url = getSERPUrl( + "searchTelemetryDomainCategorizationReportingWithoutAds.html" + ); + info("Load a SERP with organic and ad components that are non-sponsored."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + info("Assert there is a non-sponsored component on the page."); + assertSERPTelemetry([ + { + impression: { + shopping_tab_displayed: "true", + provider: "example", + source: "unknown", + tagged: "true", + is_private: "false", + is_shopping_page: "false", + partner_code: "ff", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + info("Click on the non-sponsored component."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#shopping", + {}, + tab.linkedBrowser + ); + + await BrowserTestUtils.removeTab(tab); + info("Assert no ads were visible or clicked on."); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "0", + sponsored_num_domains: "0", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + is_shopping_page: "false", + num_ads_clicked: "0", + num_ads_visible: "0", + }, + ]); + } +); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ping_submission.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ping_submission.js new file mode 100644 index 0000000000..0196483b8c --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ping_submission.js @@ -0,0 +1,302 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test ensures we are correctly submitting the custom ping for SERP + * categorization. (Please see the search component's Marionette tests for + * a test of the ping's submission upon startup.) + */ + +ChromeUtils.defineESModuleGetters(this, { + CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SERPCategorizationRecorder: "resource:///modules/SearchSERPTelemetry.sys.mjs", + TELEMETRY_CATEGORIZATION_KEY: + "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [/^https:\/\/example.com/], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); +const db = client.db; + +function sleep(ms) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + return new Promise(resolve => setTimeout(resolve, ms)); +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await db.clear(); + + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } + + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_threshold_reached() { + resetTelemetry(); + + let oldThreshold = CATEGORIZATION_SETTINGS.PING_SUBMISSION_THRESHOLD; + // For testing, it's fine to categorize fewer SERPs before sending the ping. + CATEGORIZATION_SETTINGS.PING_SUBMISSION_THRESHOLD = 2; + SERPCategorizationRecorder.uninit(); + SERPCategorizationRecorder.init(); + + Assert.equal( + null, + Glean.serp.categorization.testGetValue(), + "Should not have recorded any metrics yet." + ); + + let submitted = false; + GleanPings.serpCategorization.testBeforeNextSubmit(reason => { + submitted = true; + Assert.equal( + "threshold_reached", + reason, + "Ping submission reason should be 'threshold_reached'." + ); + }); + + // Categorize first SERP, which results in one organic and one sponsored + // reporting. + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + Assert.equal( + false, + submitted, + "Ping should not be submitted before threshold is reached." + ); + + // Categorize second SERP, which results in one organic and one sponsored + // reporting. + url = getSERPUrl("searchTelemetryDomainExtraction.html"); + info("Load a sample SERP with organic and sponsored results."); + promise = waitForPageWithCategorizedDomains(); + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + + Assert.equal( + true, + submitted, + "Ping should be submitted once threshold is reached." + ); + + CATEGORIZATION_SETTINGS.PING_SUBMISSION_THRESHOLD = oldThreshold; +}); + +add_task(async function test_quick_activity_to_inactivity_alternation() { + resetTelemetry(); + + Assert.equal( + null, + Glean.serp.categorization.testGetValue(), + "Should not have recorded any metrics yet." + ); + + let submitted = false; + GleanPings.serpCategorization.testBeforeNextSubmit(() => { + submitted = true; + }); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + let activityDetectedPromise = TestUtils.topicObserved( + "user-interaction-active" + ); + // Simulate ~2.5 seconds of activity. + for (let i = 0; i < 25; i++) { + EventUtils.synthesizeKey("KEY_Enter"); + await sleep(100); + } + await activityDetectedPromise; + + let inactivityDetectedPromise = TestUtils.topicObserved( + "user-interaction-inactive" + ); + await inactivityDetectedPromise; + + Assert.equal( + false, + submitted, + "Ping should not be submitted after a quick alternation from activity to inactivity." + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_submit_after_activity_then_inactivity() { + resetTelemetry(); + let oldActivityLimit = Services.prefs.getIntPref( + "telemetry.fog.test.activity_limit" + ); + Services.prefs.setIntPref("telemetry.fog.test.activity_limit", 2); + + Assert.equal( + null, + Glean.serp.categorization.testGetValue(), + "Should not have recorded any metrics yet." + ); + + let submitted = false; + GleanPings.serpCategorization.testBeforeNextSubmit(reason => { + submitted = true; + Assert.equal( + "inactivity", + reason, + "Ping submission reason should be 'inactivity'." + ); + }); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + let activityDetectedPromise = TestUtils.topicObserved( + "user-interaction-active" + ); + // Simulate ~2.5 seconds of activity. + for (let i = 0; i < 25; i++) { + EventUtils.synthesizeKey("KEY_Enter"); + await sleep(100); + } + await activityDetectedPromise; + + let inactivityDetectedPromise = TestUtils.topicObserved( + "user-interaction-inactive" + ); + await inactivityDetectedPromise; + + Assert.equal( + true, + submitted, + "Ping should be submitted after 2+ seconds of activity, followed by inactivity." + ); + + BrowserTestUtils.removeTab(tab); + Services.prefs.setIntPref( + "telemetry.fog.test.activity_limit", + oldActivityLimit + ); +}); + +add_task(async function test_no_observers_added_if_pref_is_off() { + resetTelemetry(); + + let prefOnActiveObserverCount = Array.from( + Services.obs.enumerateObservers("user-interaction-active") + ).length; + let prefOnInactiveObserverCount = Array.from( + Services.obs.enumerateObservers("user-interaction-inactive") + ).length; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", false]], + }); + await waitForDomainToCategoriesUninit(); + + let prefOffActiveObserverCount = Array.from( + Services.obs.enumerateObservers("user-interaction-active") + ).length; + let prefOffInactiveObserverCount = Array.from( + Services.obs.enumerateObservers("user-interaction-inactive") + ).length; + + Assert.equal( + prefOnActiveObserverCount - prefOffActiveObserverCount, + 1, + "There should be one fewer active observer when the pref is off." + ); + Assert.equal( + prefOnInactiveObserverCount - prefOffInactiveObserverCount, + 1, + "There should be one fewer inactive observer when the pref is off." + ); + + await SpecialPowers.popPrefEnv(); + await waitForDomainToCategoriesInit(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js index 4c47b0b14a..7dbf605396 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js @@ -78,6 +78,17 @@ add_setup(async function () { Assert.equal(Region.home, "DE", "Region"); registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } + Region._setHomeRegion(originalHomeRegion); Region._setCurrentRegion(originalCurrentRegion); @@ -113,6 +124,7 @@ add_task(async function test_categorize_page_with_different_region() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js index 973f17b760..3c439844d7 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js @@ -19,6 +19,7 @@ const TEST_PROVIDER_INFO = [ queryParamNames: ["s"], codeParamName: "abc", taggedCodes: ["ff"], + organicCodes: [], adServerAttributes: ["mozAttr"], nonAdsLinkRegexps: [], extraAdServersRegexps: [ @@ -56,6 +57,9 @@ const TEST_PROVIDER_INFO = [ default: true, }, ], + shoppingTab: { + regexp: "&page=shop", + }, }, ]; @@ -69,6 +73,10 @@ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + let { record, attachment } = await insertRecordIntoCollection(); categorizationRecord = record; categorizationAttachment = attachment; @@ -82,7 +90,18 @@ add_setup(async function () { await promise; registerCleanupFunction(async () => { + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; resetTelemetry(); await db.clear(); }); @@ -115,6 +134,7 @@ add_task(async function test_categorization_reporting() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -147,6 +167,7 @@ add_task(async function test_no_reporting_if_download_failure() { await promise; await BrowserTestUtils.removeTab(tab); + // We should not record telemetry if attachments weren't downloaded. assertCategorizationValues([]); // Re-insert the attachment for other tests. @@ -177,6 +198,7 @@ add_task(async function test_no_reporting_if_no_records() { await promise; await BrowserTestUtils.removeTab(tab); + // We should not record telemetry if there are no records. assertCategorizationValues([]); }); @@ -218,8 +240,46 @@ add_task(async function test_reporting_limited_to_10_domains_of_each_kind() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "12", }, ]); }); + +add_task(async function test_categorization_reporting_for_shopping_page() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + let shoppingUrl = new URL(url); + shoppingUrl.searchParams.set("page", "shop"); + shoppingUrl = shoppingUrl.toString(); + info("Load a sample shopping page SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, shoppingUrl); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + is_shopping_page: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js index 9d3ac2c931..0e2d1c07fd 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js @@ -87,9 +87,20 @@ add_setup(async function () { await promise; registerCleanupFunction(async () => { - // The scheduler uses the mock idle service. - SearchSERPCategorizationEventScheduler.uninit(); - SearchSERPCategorizationEventScheduler.init(); + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } else { + // The scheduler uses the mock idle service. + SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorizationEventScheduler.init(); + } SearchSERPTelemetry.overrideSearchTelemetryForTests(); resetTelemetry(); }); @@ -126,6 +137,7 @@ add_task(async function test_categorize_serp_and_wait() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -170,6 +182,7 @@ add_task(async function test_categorize_serp_open_multiple_tabs() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }); @@ -223,6 +236,7 @@ add_task(async function test_categorize_serp_close_tab_and_wait() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -276,6 +290,7 @@ add_task(async function test_categorize_serp_open_ad_and_wait() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "1", num_ads_visible: "2", }, diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js index c73e224eae..43c520a8d0 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js @@ -92,9 +92,20 @@ add_setup(async function () { await promise; registerCleanupFunction(async () => { - // The scheduler uses the mock idle service. - SearchSERPCategorizationEventScheduler.uninit(); - SearchSERPCategorizationEventScheduler.init(); + // Manually unload the pref so that we can check if we should wait for the + // the categories map to be un-initialized. + await SpecialPowers.popPrefEnv(); + if ( + !Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + await waitForDomainToCategoriesUninit(); + } else { + // The scheduler uses the mock idle service. + SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorizationEventScheduler.init(); + } CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = oldWakeTimeout; SearchSERPTelemetry.overrideSearchTelemetryForTests(); resetTelemetry(); @@ -138,6 +149,7 @@ add_task(async function test_categorize_serp_and_sleep() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, @@ -195,6 +207,7 @@ add_task(async function test_categorize_serp_and_sleep_not_long_enough() { partner_code: "ff", provider: "example", tagged: "true", + is_shopping_page: "false", num_ads_clicked: "0", num_ads_visible: "2", }, diff --git a/browser/components/search/test/browser/telemetry/head.js b/browser/components/search/test/browser/telemetry/head.js index b798099bdd..ecc6e38fa9 100644 --- a/browser/components/search/test/browser/telemetry/head.js +++ b/browser/components/search/test/browser/telemetry/head.js @@ -4,11 +4,14 @@ ChromeUtils.defineESModuleGetters(this, { ADLINK_CHECK_TIMEOUT_MS: "resource:///actors/SearchSERPTelemetryChild.sys.mjs", + CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs", CustomizableUITestUtils: "resource://testing-common/CustomizableUITestUtils.sys.mjs", Region: "resource://gre/modules/Region.sys.mjs", RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", SEARCH_TELEMETRY_SHARED: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs", SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", @@ -193,11 +196,10 @@ async function assertSearchSourcesTelemetry( } function resetTelemetry() { - // TODO Bug 1868476: Replace when we're using Glean telemetry. - fakeTelemetryStorage = []; searchCounts.clear(); Services.telemetry.clearScalars(); Services.fog.testResetFOG(); + SERPCategorizationRecorder.testReset(); } /** @@ -377,23 +379,6 @@ function assertSERPTelemetry(expectedEvents) { ); } -// TODO Bug 1868476: Replace when we're using Glean telemetry. -let categorizationSandbox; -let fakeTelemetryStorage = []; -add_setup(function () { - categorizationSandbox = sinon.createSandbox(); - categorizationSandbox - .stub(SERPCategorizationRecorder, "recordCategorizationTelemetry") - .callsFake(input => { - fakeTelemetryStorage.push(input); - }); - - registerCleanupFunction(() => { - categorizationSandbox.restore(); - fakeTelemetryStorage = []; - }); -}); - async function openSerpInNewTab(url, expectedAds = true) { let promise; if (expectedAds) { @@ -435,12 +420,11 @@ async function synthesizePageAction({ } function assertCategorizationValues(expectedResults) { - // TODO Bug 1868476: Replace with calls to Glean telemetry. - let actualResults = [...fakeTelemetryStorage]; + let actualResults = Glean.serp.categorization.testGetValue() ?? []; Assert.equal( - expectedResults.length, actualResults.length, + expectedResults.length, "Should have the correct number of categorization impressions." ); @@ -458,7 +442,7 @@ function assertCategorizationValues(expectedResults) { } } for (let actual of actualResults) { - for (let key in actual) { + for (let key in actual.extra) { keys.add(key); } } @@ -467,14 +451,21 @@ function assertCategorizationValues(expectedResults) { for (let index = 0; index < expectedResults.length; ++index) { info(`Checking categorization at index: ${index}`); let expected = expectedResults[index]; - let actual = actualResults[index]; + let actual = actualResults[index].extra; + + Assert.ok( + Number(actual?.organic_num_domains) <= + CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE, + "Number of organic domains categorized should not exceed threshold." + ); + + Assert.ok( + Number(actual?.sponsored_num_domains) <= + CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE, + "Number of sponsored domains categorized should not exceed threshold." + ); + for (let key of keys) { - // TODO Bug 1868476: This conversion to strings is to mimic Glean - // converting all values into strings. Once we receive real values from - // Glean, it can be removed. - if (actual[key] != null && typeof actual[key] !== "string") { - actual[key] = actual[key].toString(); - } Assert.equal( actual[key], expected[key], @@ -508,6 +499,14 @@ function waitForDomainToCategoriesUpdate() { return TestUtils.topicObserved("domain-to-categories-map-update-complete"); } +function waitForDomainToCategoriesInit() { + return TestUtils.topicObserved("domain-to-categories-map-init"); +} + +function waitForDomainToCategoriesUninit() { + return TestUtils.topicObserved("domain-to-categories-map-uninit"); +} + registerCleanupFunction(async () => { await PlacesUtils.history.clear(); }); diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReportingWithoutAds.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReportingWithoutAds.html new file mode 100644 index 0000000000..13d023e45d --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReportingWithoutAds.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <div> + <a id="shopping" href="https://www.example.org/shopping">Shopping</a> + </div> + <div id="results"> + <div class="organic"> + <a href="https://www.foobar.org">Link</a> + </div> + </div> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html index 28c31af959..fe52bb8b48 100644 --- a/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html @@ -256,6 +256,37 @@ </div> </div> </div> + + <div id="test26"> + <div id="b_results"> + <div class="b_algo"> + <div class="b_attribution"> + <cite>https://organic.com/cats</cite> + </div> + </div> + </div> + </div> + + <div id="test27"> + <div id="b_results"> + <div class="b_algo"> + <div class="b_attribution"> + <cite>https://organic.com/testing?q=cats</cite> + </div> + </div> + </div> + </div> + + <div id="test28"> + <div id="b_results"> + <div class="b_algo"> + <div class="b_attribution"> + <span>HTTPS</span> + <cite>en.wikipedia.org/wiki/Cat</cite> + </div> + </div> + </div> + </div> </div> </body> </html> diff --git a/browser/components/search/test/marionette/manifest.toml b/browser/components/search/test/marionette/manifest.toml index 152442bc5b..9cc88e9f84 100644 --- a/browser/components/search/test/marionette/manifest.toml +++ b/browser/components/search/test/marionette/manifest.toml @@ -1,4 +1,6 @@ [DEFAULT] run-if = ["buildapp == 'browser'"] +["include:telemetry/manifest.toml"] + ["test_engines_on_restart.py"] diff --git a/browser/components/search/test/marionette/telemetry/manifest.toml b/browser/components/search/test/marionette/telemetry/manifest.toml new file mode 100644 index 0000000000..1fe35945c9 --- /dev/null +++ b/browser/components/search/test/marionette/telemetry/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +run-if = ["buildapp == 'browser'"] + +["test_ping_submitted.py"] diff --git a/browser/components/search/test/marionette/telemetry/test_ping_submitted.py b/browser/components/search/test/marionette/telemetry/test_ping_submitted.py new file mode 100644 index 0000000000..cefe2d72d1 --- /dev/null +++ b/browser/components/search/test/marionette/telemetry/test_ping_submitted.py @@ -0,0 +1,89 @@ +# -*- coding: utf-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/. + +from marionette_driver import Wait +from marionette_harness.marionette_test import MarionetteTestCase + + +class TestPingSubmitted(MarionetteTestCase): + def setUp(self): + super(TestPingSubmitted, self).setUp() + + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + + self.marionette.enforce_gecko_prefs( + { + "datareporting.healthreport.uploadEnabled": True, + "telemetry.fog.test.localhost_port": 3000, + "browser.search.log": True, + } + ) + # The categorization ping is submitted on startup. If anything delays + # its initialization, turning the preference on and immediately + # attaching a categorization event could result in the ping being + # submitted after the test event is reported but before the browser + # restarts. + script = """ + let [outerResolve] = arguments; + (async () => { + if (!Services.prefs.getBoolPref("browser.search.serpEventTelemetryCategorization.enabled")) { + let inited = new Promise(innerResolve => { + Services.obs.addObserver(function callback() { + Services.obs.removeObserver(callback, "categorization-recorder-init"); + innerResolve(); + }, "categorization-recorder-init"); + }); + Services.prefs.setBoolPref("browser.search.serpEventTelemetryCategorization.enabled", true); + await inited; + } + })().then(outerResolve); + """ + self.marionette.execute_async_script(script) + + def test_ping_submit_on_start(self): + # Record an event for the ping to eventually submit. + self.marionette.execute_script( + """ + Glean.serp.categorization.record({ + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: "124", + channel: "nightly", + region: "US", + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }); + """ + ) + + Wait(self.marionette, timeout=60).until( + lambda _: self.marionette.execute_script( + """ + return (Glean.serp.categorization.testGetValue()?.length ?? 0) == 1; + """ + ), + message="Should have recorded a SERP categorization event before restart.", + ) + + self.marionette.restart(clean=False, in_app=True) + + Wait(self.marionette, timeout=60).until( + lambda _: self.marionette.execute_script( + """ + return (Glean.serp.categorization.testGetValue()?.length ?? 0) == 0; + """ + ), + message="SERP categorization should have been sent some time after restart.", + ) diff --git a/browser/components/search/test/unit/corruptDB.sqlite b/browser/components/search/test/unit/corruptDB.sqlite Binary files differnew file mode 100644 index 0000000000..b234246cac --- /dev/null +++ b/browser/components/search/test/unit/corruptDB.sqlite diff --git a/browser/components/search/test/unit/test_domain_to_categories_store.js b/browser/components/search/test/unit/test_domain_to_categories_store.js new file mode 100644 index 0000000000..e3af0c8de5 --- /dev/null +++ b/browser/components/search/test/unit/test_domain_to_categories_store.js @@ -0,0 +1,361 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Ensure that the domain to categories store public methods work as expected + * and it handles all error cases as expected. + */ + +ChromeUtils.defineESModuleGetters(this, { + CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs", + DomainToCategoriesStore: "resource:///modules/SearchSERPTelemetry.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +let store = new DomainToCategoriesStore(); +let defaultStorePath; +let fileContents = [convertToBuffer({ foo: [0, 1] })]; + +async function createCorruptedStore() { + info("Create a corrupted store."); + let storePath = PathUtils.join( + PathUtils.profileDir, + CATEGORIZATION_SETTINGS.STORE_FILE + ); + let src = PathUtils.join(do_get_cwd().path, "corruptDB.sqlite"); + await IOUtils.copy(src, storePath); + Assert.ok(await IOUtils.exists(storePath), "Store exists."); + return storePath; +} + +function convertToBuffer(obj) { + return new TextEncoder().encode(JSON.stringify(obj)).buffer; +} + +/** + * Deletes data from the store and removes any files that were generated due + * to them. + */ +async function cleanup() { + info("Clean up store."); + + // In these tests, we sometimes use read-only files to test permission error + // handling. On Windows, we have to change it to writable to allow for their + // deletion so that subsequent tests aren't affected. + if ( + (await IOUtils.exists(defaultStorePath)) && + Services.appinfo.OS == "WINNT" + ) { + await IOUtils.setPermissions(defaultStorePath, 0o600); + } + + await store.testDelete(); + Assert.equal(store.empty, true, "Store should be empty."); + Assert.equal(await IOUtils.exists(defaultStorePath), false, "Store exists."); + Assert.equal( + await store.getVersion(), + 0, + "Version number should be 0 when store is empty." + ); + + await store.uninit(); +} + +async function createReadOnlyStore() { + info("Create a store that can't be read."); + let storePath = PathUtils.join( + PathUtils.profileDir, + CATEGORIZATION_SETTINGS.STORE_FILE + ); + + let conn = await Sqlite.openConnection({ path: storePath }); + await conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)"); + await conn.close(); + + await changeStoreToReadOnly(); +} + +async function changeStoreToReadOnly() { + info("Change store to read only."); + let storePath = PathUtils.join( + PathUtils.profileDir, + CATEGORIZATION_SETTINGS.STORE_FILE + ); + let stat = await IOUtils.stat(storePath); + await IOUtils.setPermissions(storePath, 0o444); + stat = await IOUtils.stat(storePath); + Assert.equal(stat.permissions, 0o444, "Permissions should be read only."); + Assert.ok(await IOUtils.exists(storePath), "Store exists."); +} + +add_setup(async function () { + // We need a profile directory to create the store and open a connection. + do_get_profile(); + defaultStorePath = PathUtils.join( + PathUtils.profileDir, + CATEGORIZATION_SETTINGS.STORE_FILE + ); + registerCleanupFunction(async () => { + await cleanup(); + }); +}); + +// Ensure the test only function deletes the store. +add_task(async function delete_store() { + let storePath = await createCorruptedStore(); + await store.testDelete(); + Assert.ok(!(await IOUtils.exists(storePath)), "Store doesn't exist."); +}); + +/** + * These tests check common no fail scenarios. + */ + +add_task(async function init_insert_uninit() { + await store.init(); + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal( + await store.getVersion(), + 0, + "Version number should not be set." + ); + Assert.equal(store.empty, true, "Store should be empty."); + + info("Try inserting after init."); + await store.insertFileContents(fileContents, 1); + + result = await store.getCategories("foo"); + Assert.deepEqual(result, [0, 1], "foo should have a matching result."); + Assert.equal(await store.getVersion(), 1, "Version number should be set."); + Assert.equal(store.empty, false, "Store should not be empty."); + + info("Un-init store."); + await store.uninit(); + + result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should be removed from store."); + Assert.equal(store.empty, true, "Store should be empty."); + Assert.equal(await store.getVersion(), 0, "Version should be reset."); + + await cleanup(); +}); + +add_task(async function insert_and_re_init() { + await store.init(); + await store.insertFileContents(fileContents, 20240202); + + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [0, 1], "foo should have a matching result."); + Assert.equal( + await store.getVersion(), + 20240202, + "Version number should be set." + ); + Assert.equal(store.empty, false, "Is store empty."); + + info("Simulate a restart."); + await store.uninit(); + await store.init(); + + result = await store.getCategories("foo"); + Assert.deepEqual( + result, + [0, 1], + "After restart, foo should still be in the store." + ); + Assert.equal( + await store.getVersion(), + 20240202, + "Version number should still be in the store." + ); + Assert.equal(store.empty, false, "Is store empty."); + + await cleanup(); +}); + +// Simulate consecutive updates. +add_task(async function insert_multiple_times() { + await store.init(); + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal( + await store.getVersion(), + 0, + "Version number should not be set." + ); + Assert.equal(store.empty, true, "Is store empty."); + + for (let i = 0; i < 3; ++i) { + info("Try inserting after init."); + await store.insertFileContents(fileContents, 1); + + result = await store.getCategories("foo"); + Assert.deepEqual(result, [0, 1], "foo should have a matching result."); + Assert.equal(store.empty, false, "Is store empty."); + Assert.equal(await store.getVersion(), 1, "Version number is set."); + + await store.dropData(); + result = await store.getCategories("foo"); + Assert.deepEqual( + result, + [], + "After dropping data, foo should no longer have a matching result." + ); + Assert.equal(await store.getVersion(), 0, "Version should be reset."); + Assert.equal(store.empty, true, "Is store empty."); + } + + await cleanup(); +}); + +/** + * The following tests check failures on store initialization. + */ + +add_task(async function init_with_corrupted_store() { + await createCorruptedStore(); + + info("Initialize the store."); + await store.init(); + + info("Try inserting after the corrupted store was replaced."); + await store.insertFileContents(fileContents, 1); + + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [0, 1], "foo should have a matching result."); + Assert.equal(await store.getVersion(), 1, "Version number is set."); + Assert.equal(store.empty, false, "Is store empty."); + + await cleanup(); +}); + +add_task(async function init_with_unfixable_store() { + let sandbox = sinon.createSandbox(); + sandbox.stub(Sqlite, "openConnection").throws(); + + info("Initialize the store."); + await store.init(); + + info("Try inserting content even if the connection is impossible to fix."); + await store.dropData(); + await store.insertFileContents(fileContents, 20240202); + + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal(await store.getVersion(), 0, "Version should be reset."); + Assert.equal(store.empty, true, "Store should be empty."); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function init_read_only_store() { + await createReadOnlyStore(); + await store.init(); + + info("Insert contents into the store."); + await store.insertFileContents(fileContents, 20240202); + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal( + await store.getVersion(), + 0, + "Version number should not be set." + ); + Assert.equal(store.empty, true, "Store should be empty."); + + await cleanup(); +}); + +add_task(async function init_close_to_shutdown() { + let sandbox = sinon.createSandbox(); + sandbox.stub(Sqlite.shutdown, "addBlocker").throws(new Error()); + await store.init(); + + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal( + await store.getVersion(), + 0, + "Version number should not be set." + ); + Assert.equal(store.empty, true, "Store should be empty."); + + sandbox.restore(); + await cleanup(); +}); + +/** + * The following tests check error handling when inserting data into the store. + */ + +add_task(async function insert_broken_file() { + await store.init(); + + Assert.equal( + await store.getVersion(), + 0, + "Version number should not be set." + ); + + info("Try inserting one valid file and an invalid file."); + let contents = [...fileContents, new ArrayBuffer(0).buffer]; + await store.insertFileContents(contents, 20240202); + + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal(await store.getVersion(), 0, "Version should remain unset."); + Assert.equal(store.empty, true, "Store should remain empty."); + + await cleanup(); +}); + +add_task(async function insert_into_read_only_store() { + await createReadOnlyStore(); + await store.init(); + + await store.dropData(); + await store.insertFileContents(fileContents, 20240202); + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [], "foo should not have a result."); + Assert.equal(await store.getVersion(), 0, "Version should remain unset."); + Assert.equal(store.empty, true, "Store should remain empty."); + + await cleanup(); +}); + +// If the store becomes read only with content already inside of it, +// the next time we try opening it, we'll encounter an error trying to write to +// it. Since we are no longer able to manipulate it, the results should always +// be empty. +add_task(async function restart_with_read_only_store() { + await store.init(); + await store.insertFileContents(fileContents, 20240202); + + info("Check store has content."); + let result = await store.getCategories("foo"); + Assert.deepEqual(result, [0, 1], "foo should have a matching result."); + Assert.equal( + await store.getVersion(), + 20240202, + "Version number should be set." + ); + Assert.equal(store.empty, false, "Store should not be empty."); + + await changeStoreToReadOnly(); + await store.uninit(); + await store.init(); + + result = await store.getCategories("foo"); + Assert.deepEqual( + result, + [], + "foo should no longer have a matching value from the store." + ); + Assert.equal(await store.getVersion(), 0, "Version number should be unset."); + Assert.equal(store.empty, true, "Store should be empty."); + + await cleanup(); +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js b/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js index 40d38efbba..2351347d77 100644 --- a/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js @@ -9,6 +9,7 @@ ChromeUtils.defineESModuleGetters(this, { RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", SearchSERPDomainToCategoriesMap: "resource:///modules/SearchSERPTelemetry.sys.mjs", TELEMETRY_CATEGORIZATION_KEY: @@ -158,7 +159,7 @@ add_task(async function test_initial_import() { // Clean up. await db.clear(); - SearchSERPDomainToCategoriesMap.uninit(); + await SearchSERPDomainToCategoriesMap.uninit(true); }); add_task(async function test_update_records() { @@ -219,7 +220,7 @@ add_task(async function test_update_records() { // Clean up. await db.clear(); - SearchSERPDomainToCategoriesMap.uninit(); + await SearchSERPDomainToCategoriesMap.uninit(true); }); add_task(async function test_delayed_initial_import() { @@ -273,7 +274,7 @@ add_task(async function test_delayed_initial_import() { // Clean up. await db.clear(); - SearchSERPDomainToCategoriesMap.uninit(); + await SearchSERPDomainToCategoriesMap.uninit(true); }); add_task(async function test_remove_record() { @@ -332,7 +333,7 @@ add_task(async function test_remove_record() { // Clean up. await db.clear(); - SearchSERPDomainToCategoriesMap.uninit(); + await SearchSERPDomainToCategoriesMap.uninit(true); }); add_task(async function test_different_versions_coexisting() { @@ -380,7 +381,7 @@ add_task(async function test_different_versions_coexisting() { // Clean up. await db.clear(); - SearchSERPDomainToCategoriesMap.uninit(); + await SearchSERPDomainToCategoriesMap.uninit(true); }); add_task(async function test_download_error() { @@ -449,5 +450,67 @@ add_task(async function test_download_error() { // Clean up. await db.clear(); - SearchSERPDomainToCategoriesMap.uninit(); + await SearchSERPDomainToCategoriesMap.uninit(true); +}); + +add_task(async function test_mock_restart() { + info("Create record containing domain_category_mappings_2a.json attachment."); + let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); + await db.create(record2a); + + info("Create record containing domain_category_mappings_2b.json attachment."); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + await db.create(record2b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPCategorization.init(); + await promise; + + Assert.deepEqual( + await SearchSERPDomainToCategoriesMap.get("example.com"), + [ + { + category: 1, + score: 80, + }, + ], + "Should have a record." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be the latest." + ); + + info("Mock a restart by un-initializing the map."); + await SearchSERPCategorization.uninit(); + promise = waitForDomainToCategoriesUpdate(); + await SearchSERPCategorization.init(); + await promise; + + Assert.deepEqual( + await SearchSERPDomainToCategoriesMap.get("example.com"), + [ + { + category: 1, + score: 80, + }, + ], + "Should have a record." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be the latest." + ); + + // Clean up. + await db.clear(); + await SearchSERPDomainToCategoriesMap.uninit(true); }); diff --git a/browser/components/search/test/unit/test_search_telemetry_config_validation.js b/browser/components/search/test/unit/test_search_telemetry_config_validation.js index 8897b1e7c7..d14f7a3918 100644 --- a/browser/components/search/test/unit/test_search_telemetry_config_validation.js +++ b/browser/components/search/test/unit/test_search_telemetry_config_validation.js @@ -57,7 +57,7 @@ function disallowAdditionalProperties(section) { add_task(async function test_search_telemetry_validates_to_schema() { let schema = await IOUtils.readJSON( - PathUtils.join(do_get_cwd().path, "search-telemetry-schema.json") + PathUtils.join(do_get_cwd().path, "search-telemetry-v2-schema.json") ); disallowAdditionalProperties(schema); diff --git a/browser/components/search/test/unit/test_ui_schemas_valid.js b/browser/components/search/test/unit/test_ui_schemas_valid.js new file mode 100644 index 0000000000..3396f38238 --- /dev/null +++ b/browser/components/search/test/unit/test_ui_schemas_valid.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let schemas = [ + ["search-telemetry-v2-schema.json", "search-telemetry-v2-ui-schema.json"], +]; + +async function checkUISchemaValid(configSchema, uiSchema) { + for (let key of Object.keys(configSchema.properties)) { + Assert.ok( + uiSchema["ui:order"].includes(key), + `Should have ${key} listed at the top-level of the ui schema` + ); + } +} + +add_task(async function test_ui_schemas_valid() { + for (let [schema, uiSchema] of schemas) { + info(`Validating ${uiSchema} has every top-level from ${schema}`); + let schemaData = await IOUtils.readJSON( + PathUtils.join(do_get_cwd().path, schema) + ); + let uiSchemaData = await IOUtils.readJSON( + PathUtils.join(do_get_cwd().path, uiSchema) + ); + + await checkUISchemaValid(schemaData, uiSchemaData); + } +}); diff --git a/browser/components/search/test/unit/xpcshell.toml b/browser/components/search/test/unit/xpcshell.toml index 423d218d19..24e1d78eb5 100644 --- a/browser/components/search/test/unit/xpcshell.toml +++ b/browser/components/search/test/unit/xpcshell.toml @@ -6,6 +6,9 @@ prefs = ["browser.search.log=true"] skip-if = ["os == 'android'"] # bug 1730213 firefox-appdir = "browser" +["test_domain_to_categories_store.js"] +support-files = ["corruptDB.sqlite"] + ["test_search_telemetry_categorization_logic.js"] ["test_search_telemetry_categorization_sync.js"] @@ -14,7 +17,13 @@ prefs = ["browser.search.serpEventTelemetryCategorization.enabled=true"] ["test_search_telemetry_compare_urls.js"] ["test_search_telemetry_config_validation.js"] -support-files = ["../../schema/search-telemetry-schema.json"] +support-files = ["../../schema/search-telemetry-v2-schema.json"] + +["test_ui_schemas_valid.js"] +support-files = [ + "../../schema/search-telemetry-v2-schema.json", + "../../schema/search-telemetry-v2-ui-schema.json", +] ["test_urlTelemetry.js"] diff --git a/browser/components/sessionstore/ContentRestore.sys.mjs b/browser/components/sessionstore/ContentRestore.sys.mjs deleted file mode 100644 index e55772cab3..0000000000 --- a/browser/components/sessionstore/ContentRestore.sys.mjs +++ /dev/null @@ -1,435 +0,0 @@ -/* 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/. */ - -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", - Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs", -}); - -/** - * This module implements the content side of session restoration. The chrome - * side is handled by SessionStore.sys.mjs. The functions in this module are called - * by content-sessionStore.js based on messages received from SessionStore.sys.mjs - * (or, in one case, based on a "load" event). Each tab has its own - * ContentRestore instance, constructed by content-sessionStore.js. - * - * In a typical restore, content-sessionStore.js will call the following based - * on messages and events it receives: - * - * restoreHistory(tabData, loadArguments, callbacks) - * Restores the tab's history and session cookies. - * restoreTabContent(loadArguments, finishCallback) - * Starts loading the data for the current page to restore. - * restoreDocument() - * Restore form and scroll data. - * - * When the page has been loaded from the network, we call finishCallback. It - * should send a message to SessionStore.sys.mjs, which may cause other tabs to be - * restored. - * - * When the page has finished loading, a "load" event will trigger in - * content-sessionStore.js, which will call restoreDocument. At that point, - * form data is restored and the restore is complete. - * - * At any time, SessionStore.sys.mjs can cancel the ongoing restore by sending a - * reset message, which causes resetRestore to be called. At that point it's - * legal to begin another restore. - */ -export function ContentRestore(chromeGlobal) { - let internal = new ContentRestoreInternal(chromeGlobal); - let external = {}; - - let EXPORTED_METHODS = [ - "restoreHistory", - "restoreTabContent", - "restoreDocument", - "resetRestore", - ]; - - for (let method of EXPORTED_METHODS) { - external[method] = internal[method].bind(internal); - } - - return Object.freeze(external); -} - -function ContentRestoreInternal(chromeGlobal) { - this.chromeGlobal = chromeGlobal; - - // The following fields are only valid during certain phases of the restore - // process. - - // The tabData for the restore. Set in restoreHistory and removed in - // restoreTabContent. - this._tabData = null; - - // Contains {entry, scrollPositions, formdata}, where entry is a - // single entry from the tabData.entries array. Set in - // restoreTabContent and removed in restoreDocument. - this._restoringDocument = null; - - // This listener is used to detect reloads on restoring tabs. Set in - // restoreHistory and removed in restoreTabContent. - this._historyListener = null; - - // This listener detects when a pending tab starts loading (when not - // initiated by sessionstore) and when a restoring tab has finished loading - // data from the network. Set in restoreHistory() and restoreTabContent(), - // removed in resetRestore(). - this._progressListener = null; -} - -/** - * The API for the ContentRestore module. Methods listed in EXPORTED_METHODS are - * public. - */ -ContentRestoreInternal.prototype = { - get docShell() { - return this.chromeGlobal.docShell; - }, - - /** - * Starts the process of restoring a tab. The tabData to be restored is passed - * in here and used throughout the restoration. The epoch (which must be - * non-zero) is passed through to all the callbacks. If a load in the tab - * is started while it is pending, the appropriate callbacks are called. - */ - restoreHistory(tabData, loadArguments, callbacks) { - this._tabData = tabData; - - // In case about:blank isn't done yet. - let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); - webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); - - // Make sure currentURI is set so that switch-to-tab works before the tab is - // restored. We'll reset this to about:blank when we try to restore the tab - // to ensure that docshell doeesn't get confused. Don't bother doing this if - // we're restoring immediately due to a process switch. It just causes the - // URL bar to be temporarily blank. - let activeIndex = tabData.index - 1; - let activePageData = tabData.entries[activeIndex] || {}; - let uri = activePageData.url || null; - if (uri && !loadArguments) { - webNavigation.setCurrentURIForSessionStore(Services.io.newURI(uri)); - } - - lazy.SessionHistory.restore(this.docShell, tabData); - - // Add a listener to watch for reloads. - let listener = new HistoryListener(this.docShell, () => { - // On reload, restore tab contents. - this.restoreTabContent(null, false, callbacks.onLoadFinished); - }); - - webNavigation.sessionHistory.legacySHistory.addSHistoryListener(listener); - this._historyListener = listener; - - // Make sure to reset the capabilities and attributes in case this tab gets - // reused. - SessionStoreUtils.restoreDocShellCapabilities( - this.docShell, - tabData.disallow - ); - - // Add a progress listener to correctly handle browser.loadURI() - // calls from foreign code. - this._progressListener = new ProgressListener(this.docShell, { - onStartRequest: () => { - // Some code called browser.loadURI() on a pending tab. It's safe to - // assume we don't care about restoring scroll or form data. - this._tabData = null; - - // Listen for the tab to finish loading. - this.restoreTabContentStarted(callbacks.onLoadFinished); - - // Notify the parent. - callbacks.onLoadStarted(); - }, - }); - }, - - /** - * Start loading the current page. When the data has finished loading from the - * network, finishCallback is called. Returns true if the load was successful. - */ - restoreTabContent(loadArguments, isRemotenessUpdate, finishCallback) { - let tabData = this._tabData; - this._tabData = null; - - let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); - - // Listen for the tab to finish loading. - this.restoreTabContentStarted(finishCallback); - - // Reset the current URI to about:blank. We changed it above for - // switch-to-tab, but now it must go back to the correct value before the - // load happens. Don't bother doing this if we're restoring immediately - // due to a process switch. - if (!isRemotenessUpdate) { - webNavigation.setCurrentURIForSessionStore( - Services.io.newURI("about:blank") - ); - } - - try { - if (loadArguments) { - // If the load was started in another process, and the in-flight channel - // was redirected into this process, resume that load within our process. - // - // NOTE: In this case `isRemotenessUpdate` must be true. - webNavigation.resumeRedirectedLoad( - loadArguments.redirectLoadSwitchId, - loadArguments.redirectHistoryIndex - ); - } else if (tabData.userTypedValue && tabData.userTypedClear) { - // If the user typed a URL into the URL bar and hit enter right before - // we crashed, we want to start loading that page again. A non-zero - // userTypedClear value means that the load had started. - // Load userTypedValue and fix up the URL if it's partial/broken. - let loadURIOptions = { - triggeringPrincipal: - Services.scriptSecurityManager.getSystemPrincipal(), - loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, - }; - webNavigation.fixupAndLoadURIString( - tabData.userTypedValue, - loadURIOptions - ); - } else if (tabData.entries.length) { - // Stash away the data we need for restoreDocument. - this._restoringDocument = { - formdata: tabData.formdata || {}, - scrollPositions: tabData.scroll || {}, - }; - - // In order to work around certain issues in session history, we need to - // force session history to update its internal index and call reload - // instead of gotoIndex. See bug 597315. - let history = webNavigation.sessionHistory.legacySHistory; - history.reloadCurrentEntry(); - } else { - // If there's nothing to restore, we should still blank the page. - let loadURIOptions = { - triggeringPrincipal: - Services.scriptSecurityManager.getSystemPrincipal(), - loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, - // Specify an override to force the load to finish in the current - // process, as tests rely on this behaviour for non-fission session - // restore. - remoteTypeOverride: Services.appinfo.remoteType, - }; - webNavigation.loadURI( - Services.io.newURI("about:blank"), - loadURIOptions - ); - } - - return true; - } catch (ex) { - if (ex instanceof Ci.nsIException) { - // Ignore page load errors, but return false to signal that the load never - // happened. - return false; - } - } - return null; - }, - - /** - * To be called after restoreHistory(). Removes all listeners needed for - * pending tabs and makes sure to notify when the tab finished loading. - */ - restoreTabContentStarted(finishCallback) { - // The reload listener is no longer needed. - this._historyListener.uninstall(); - this._historyListener = null; - - // Remove the old progress listener. - this._progressListener.uninstall(); - - // We're about to start a load. This listener will be called when the load - // has finished getting everything from the network. - this._progressListener = new ProgressListener(this.docShell, { - onStopRequest: () => { - // Call resetRestore() to reset the state back to normal. The data - // needed for restoreDocument() (which hasn't happened yet) will - // remain in _restoringDocument. - this.resetRestore(); - - finishCallback(); - }, - }); - }, - - /** - * Finish restoring the tab by filling in form data and setting the scroll - * position. The restore is complete when this function exits. It should be - * called when the "load" event fires for the restoring tab. Returns true - * if we're restoring a document. - */ - restoreDocument() { - if (!this._restoringDocument) { - return; - } - - let { formdata, scrollPositions } = this._restoringDocument; - this._restoringDocument = null; - - let window = this.docShell.domWindow; - - // Restore form data. - lazy.Utils.restoreFrameTreeData(window, formdata, (frame, data) => { - // restore() will return false, and thus abort restoration for the - // current |frame| and its descendants, if |data.url| is given but - // doesn't match the loaded document's URL. - return SessionStoreUtils.restoreFormData(frame.document, data); - }); - - // Restore scroll data. - lazy.Utils.restoreFrameTreeData(window, scrollPositions, (frame, data) => { - if (data.scroll) { - SessionStoreUtils.restoreScrollPosition(frame, data); - } - }); - }, - - /** - * Cancel an ongoing restore. This function can be called any time between - * restoreHistory and restoreDocument. - * - * This function is called externally (if a restore is canceled) and - * internally (when the loads for a restore have finished). In the latter - * case, it's called before restoreDocument, so it cannot clear - * _restoringDocument. - */ - resetRestore() { - this._tabData = null; - - if (this._historyListener) { - this._historyListener.uninstall(); - } - this._historyListener = null; - - if (this._progressListener) { - this._progressListener.uninstall(); - } - this._progressListener = null; - }, -}; - -/* - * This listener detects when a page being restored is reloaded. It triggers a - * callback and cancels the reload. The callback will send a message to - * SessionStore.sys.mjs so that it can restore the content immediately. - */ -function HistoryListener(docShell, callback) { - let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); - webNavigation.sessionHistory.legacySHistory.addSHistoryListener(this); - - this.webNavigation = webNavigation; - this.callback = callback; -} -HistoryListener.prototype = { - QueryInterface: ChromeUtils.generateQI([ - "nsISHistoryListener", - "nsISupportsWeakReference", - ]), - - uninstall() { - let shistory = this.webNavigation.sessionHistory.legacySHistory; - if (shistory) { - shistory.removeSHistoryListener(this); - } - }, - - OnHistoryGotoIndex() {}, - OnHistoryPurge() {}, - OnHistoryReplaceEntry() {}, - - // This will be called for a pending tab when loadURI(uri) is called where - // the given |uri| only differs in the fragment. - OnHistoryNewEntry(newURI) { - let currentURI = this.webNavigation.currentURI; - - // Ignore new SHistory entries with the same URI as those do not indicate - // a navigation inside a document by changing the #hash part of the URL. - // We usually hit this when purging session history for browsers. - if (currentURI && currentURI.spec == newURI.spec) { - return; - } - - // Reset the tab's URL to what it's actually showing. Without this loadURI() - // would use the current document and change the displayed URL only. - this.webNavigation.setCurrentURIForSessionStore( - Services.io.newURI("about:blank") - ); - - // Kick off a new load so that we navigate away from about:blank to the - // new URL that was passed to loadURI(). The new load will cause a - // STATE_START notification to be sent and the ProgressListener will then - // notify the parent and do the rest. - let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; - let loadURIOptions = { - triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), - loadFlags, - }; - this.webNavigation.loadURI(newURI, loadURIOptions); - }, - - OnHistoryReload() { - this.callback(); - - // Cancel the load. - return false; - }, -}; - -/** - * This class informs SessionStore.sys.mjs whenever the network requests for a - * restoring page have completely finished. We only restore three tabs - * simultaneously, so this is the signal for SessionStore.sys.mjs to kick off - * another restore (if there are more to do). - * - * The progress listener is also used to be notified when a load not initiated - * by sessionstore starts. Pending tabs will then need to be marked as no - * longer pending. - */ -function ProgressListener(docShell, callbacks) { - let webProgress = docShell - .QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIWebProgress); - webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); - - this.webProgress = webProgress; - this.callbacks = callbacks; -} - -ProgressListener.prototype = { - QueryInterface: ChromeUtils.generateQI([ - "nsIWebProgressListener", - "nsISupportsWeakReference", - ]), - - uninstall() { - this.webProgress.removeProgressListener(this); - }, - - onStateChange(webProgress, request, stateFlags, status) { - let { STATE_IS_WINDOW, STATE_STOP, STATE_START } = - Ci.nsIWebProgressListener; - if (!webProgress.isTopLevel || !(stateFlags & STATE_IS_WINDOW)) { - return; - } - - if (stateFlags & STATE_START && this.callbacks.onStartRequest) { - this.callbacks.onStartRequest(); - } - - if (stateFlags & STATE_STOP && this.callbacks.onStopRequest) { - this.callbacks.onStopRequest(); - } - }, -}; diff --git a/browser/components/sessionstore/ContentSessionStore.sys.mjs b/browser/components/sessionstore/ContentSessionStore.sys.mjs deleted file mode 100644 index 44f59cd39d..0000000000 --- a/browser/components/sessionstore/ContentSessionStore.sys.mjs +++ /dev/null @@ -1,685 +0,0 @@ -/* 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/. */ - -import { - clearTimeout, - setTimeoutWithTarget, -} from "resource://gre/modules/Timer.sys.mjs"; - -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs", - SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", -}); - -// This pref controls whether or not we send updates to the parent on a timeout -// or not, and should only be used for tests or debugging. -const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; - -const PREF_INTERVAL = "browser.sessionstore.interval"; - -const kNoIndex = Number.MAX_SAFE_INTEGER; -const kLastIndex = Number.MAX_SAFE_INTEGER - 1; - -class Handler { - constructor(store) { - this.store = store; - } - - get contentRestore() { - return this.store.contentRestore; - } - - get contentRestoreInitialized() { - return this.store.contentRestoreInitialized; - } - - get mm() { - return this.store.mm; - } - - get messageQueue() { - return this.store.messageQueue; - } -} - -/** - * Listens for and handles content events that we need for the - * session store service to be notified of state changes in content. - */ -class EventListener extends Handler { - constructor(store) { - super(store); - - SessionStoreUtils.addDynamicFrameFilteredListener( - this.mm, - "load", - this, - true - ); - } - - handleEvent(event) { - let { content } = this.mm; - - // Ignore load events from subframes. - if (event.target != content.document) { - return; - } - - if (content.document.documentURI.startsWith("about:reader")) { - if ( - event.type == "load" && - !content.document.body.classList.contains("loaded") - ) { - // Don't restore the scroll position of an about:reader page at this - // point; listen for the custom event dispatched from AboutReader.sys.mjs. - content.addEventListener("AboutReaderContentReady", this); - return; - } - - content.removeEventListener("AboutReaderContentReady", this); - } - - if (this.contentRestoreInitialized) { - // Restore the form data and scroll position. - this.contentRestore.restoreDocument(); - } - } -} - -/** - * Listens for changes to the session history. Whenever the user navigates - * we will collect URLs and everything belonging to session history. - * - * Causes a SessionStore:update message to be sent that contains the current - * session history. - * - * Example: - * {entries: [{url: "about:mozilla", ...}, ...], index: 1} - */ -class SessionHistoryListener extends Handler { - constructor(store) { - super(store); - - this._fromIdx = kNoIndex; - - // By adding the SHistoryListener immediately, we will unfortunately be - // notified of every history entry as the tab is restored. We don't bother - // waiting to add the listener later because these notifications are cheap. - // We will likely only collect once since we are batching collection on - // a delay. - this.mm.docShell - .QueryInterface(Ci.nsIWebNavigation) - .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview - - let webProgress = this.mm.docShell - .QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIWebProgress); - - webProgress.addProgressListener( - this, - Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT - ); - - // Collect data if we start with a non-empty shistory. - if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) { - this.collect(); - // When a tab is detached from the window, for the new window there is a - // new SessionHistoryListener created. Normally it is empty at this point - // but in a test env. the initial about:blank might have a children in which - // case we fire off a history message here with about:blank in it. If we - // don't do it ASAP then there is going to be a browser swap and the parent - // will be all confused by that message. - this.store.messageQueue.send(); - } - - // Listen for page title changes. - this.mm.addEventListener("DOMTitleChanged", this); - } - - get mm() { - return this.store.mm; - } - - uninit() { - let sessionHistory = this.mm.docShell.QueryInterface( - Ci.nsIWebNavigation - ).sessionHistory; - if (sessionHistory) { - sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview - } - } - - collect() { - // We want to send down a historychange even for full collects in case our - // session history is a partial session history, in which case we don't have - // enough information for a full update. collectFrom(-1) tells the collect - // function to collect all data avaliable in this process. - if (this.mm.docShell) { - this.collectFrom(-1); - } - } - - // History can grow relatively big with the nested elements, so if we don't have to, we - // don't want to send the entire history all the time. For a simple optimization - // we keep track of the smallest index from after any change has occured and we just send - // the elements from that index. If something more complicated happens we just clear it - // and send the entire history. We always send the additional info like the current selected - // index (so for going back and forth between history entries we set the index to kLastIndex - // if nothing else changed send an empty array and the additonal info like the selected index) - collectFrom(idx) { - if (this._fromIdx <= idx) { - // If we already know that we need to update history fromn index N we can ignore any changes - // tha happened with an element with index larger than N. - // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything - // here, and in case of navigation in the history back and forth we use kLastIndex which ignores - // only the subsequent navigations, but not any new elements added. - return; - } - - this._fromIdx = idx; - this.store.messageQueue.push("historychange", () => { - if (this._fromIdx === kNoIndex) { - return null; - } - - let history = lazy.SessionHistory.collect( - this.mm.docShell, - this._fromIdx - ); - this._fromIdx = kNoIndex; - return history; - }); - } - - handleEvent(event) { - this.collect(); - } - - OnHistoryNewEntry(newURI, oldIndex) { - // Collect the current entry as well, to make sure to collect any changes - // that were made to the entry while the document was active. - this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); - } - - OnHistoryGotoIndex() { - // We ought to collect the previously current entry as well, see bug 1350567. - this.collectFrom(kLastIndex); - } - - OnHistoryPurge() { - this.collect(); - } - - OnHistoryReload() { - this.collect(); - return true; - } - - OnHistoryReplaceEntry() { - this.collect(); - } - - /** - * @see nsIWebProgressListener.onStateChange - */ - onStateChange(webProgress, request, stateFlags, status) { - // Ignore state changes for subframes because we're only interested in the - // top-document starting or stopping its load. - if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { - return; - } - - // onStateChange will be fired when loading the initial about:blank URI for - // a browser, which we don't actually care about. This is particularly for - // the case of unrestored background tabs, where the content has not yet - // been restored: we don't want to accidentally send any updates to the - // parent when the about:blank placeholder page has loaded. - if (!this.mm.docShell.hasLoadedNonBlankURI) { - return; - } - - if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { - this.collect(); - } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { - this.collect(); - } - } -} -SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ - "nsIWebProgressListener", - "nsISHistoryListener", - "nsISupportsWeakReference", -]); - -/** - * A message queue that takes collected data and will take care of sending it - * to the chrome process. It allows flushing using synchronous messages and - * takes care of any race conditions that might occur because of that. Changes - * will be batched if they're pushed in quick succession to avoid a message - * flood. - */ -class MessageQueue extends Handler { - constructor(store) { - super(store); - - /** - * A map (string -> lazy fn) holding lazy closures of all queued data - * collection routines. These functions will return data collected from the - * docShell. - */ - this._data = new Map(); - - /** - * The delay (in ms) used to delay sending changes after data has been - * invalidated. - */ - this.BATCH_DELAY_MS = 1000; - - /** - * The minimum idle period (in ms) we need for sending data to chrome process. - */ - this.NEEDED_IDLE_PERIOD_MS = 5; - - /** - * Timeout for waiting an idle period to send data. We will set this from - * the pref "browser.sessionstore.interval". - */ - this._timeoutWaitIdlePeriodMs = null; - - /** - * The current timeout ID, null if there is no queue data. We use timeouts - * to damp a flood of data changes and send lots of changes as one batch. - */ - this._timeout = null; - - /** - * Whether or not sending batched messages on a timer is disabled. This should - * only be used for debugging or testing. If you need to access this value, - * you should probably use the timeoutDisabled getter. - */ - this._timeoutDisabled = false; - - /** - * True if there is already a send pending idle dispatch, set to prevent - * scheduling more than one. If false there may or may not be one scheduled. - */ - this._idleScheduled = false; - - this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); - this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL); - - Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); - Services.prefs.addObserver(PREF_INTERVAL, this); - } - - /** - * True if batched messages are not being fired on a timer. This should only - * ever be true when debugging or during tests. - */ - get timeoutDisabled() { - return this._timeoutDisabled; - } - - /** - * Disables sending batched messages on a timer. Also cancels any pending - * timers. - */ - set timeoutDisabled(val) { - this._timeoutDisabled = val; - - if (val && this._timeout) { - clearTimeout(this._timeout); - this._timeout = null; - } - } - - uninit() { - Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this); - Services.prefs.removeObserver(PREF_INTERVAL, this); - this.cleanupTimers(); - } - - /** - * Cleanup pending idle callback and timer. - */ - cleanupTimers() { - this._idleScheduled = false; - if (this._timeout) { - clearTimeout(this._timeout); - this._timeout = null; - } - } - - observe(subject, topic, data) { - if (topic == "nsPref:changed") { - switch (data) { - case TIMEOUT_DISABLED_PREF: - this.timeoutDisabled = Services.prefs.getBoolPref( - TIMEOUT_DISABLED_PREF - ); - break; - case PREF_INTERVAL: - this._timeoutWaitIdlePeriodMs = - Services.prefs.getIntPref(PREF_INTERVAL); - break; - default: - console.error("received unknown message '" + data + "'"); - break; - } - } - } - - /** - * Pushes a given |value| onto the queue. The given |key| represents the type - * of data that is stored and can override data that has been queued before - * but has not been sent to the parent process, yet. - * - * @param key (string) - * A unique identifier specific to the type of data this is passed. - * @param fn (function) - * A function that returns the value that will be sent to the parent - * process. - */ - push(key, fn) { - this._data.set(key, fn); - - if (!this._timeout && !this._timeoutDisabled) { - // Wait a little before sending the message to batch multiple changes. - this._timeout = setTimeoutWithTarget( - () => this.sendWhenIdle(), - this.BATCH_DELAY_MS, - this.mm.tabEventTarget - ); - } - } - - /** - * Sends queued data when the remaining idle time is enough or waiting too - * long; otherwise, request an idle time again. If the |deadline| is not - * given, this function is going to schedule the first request. - * - * @param deadline (object) - * An IdleDeadline object passed by idleDispatch(). - */ - sendWhenIdle(deadline) { - if (!this.mm.content) { - // The frameloader is being torn down. Nothing more to do. - return; - } - - if (deadline) { - if ( - deadline.didTimeout || - deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS - ) { - this.send(); - return; - } - } else if (this._idleScheduled) { - // Bail out if there's a pending run. - return; - } - ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { - timeout: this._timeoutWaitIdlePeriodMs, - }); - this._idleScheduled = true; - } - - /** - * Sends queued data to the chrome process. - * - * @param options (object) - * {flushID: 123} to specify that this is a flush - * {isFinal: true} to signal this is the final message sent on unload - */ - send(options = {}) { - // Looks like we have been called off a timeout after the tab has been - // closed. The docShell is gone now and we can just return here as there - // is nothing to do. - if (!this.mm.docShell) { - return; - } - - this.cleanupTimers(); - - let flushID = (options && options.flushID) || 0; - let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS"; - - let data = {}; - for (let [key, func] of this._data) { - if (key != "isPrivate") { - TelemetryStopwatch.startKeyed(histID, key); - } - - let value = func(); - - if (key != "isPrivate") { - TelemetryStopwatch.finishKeyed(histID, key); - } - - if (value || (key != "storagechange" && key != "historychange")) { - data[key] = value; - } - } - - this._data.clear(); - - try { - // Send all data to the parent process. - this.mm.sendAsyncMessage("SessionStore:update", { - data, - flushID, - isFinal: options.isFinal || false, - epoch: this.store.epoch, - }); - } catch (ex) { - if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { - Services.telemetry - .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM") - .add(1); - this.mm.sendAsyncMessage("SessionStore:error"); - } - } - } -} - -/** - * Listens for and handles messages sent by the session store service. - */ -const MESSAGES = [ - "SessionStore:restoreHistory", - "SessionStore:restoreTabContent", - "SessionStore:resetRestore", - "SessionStore:flush", - "SessionStore:prepareForProcessChange", -]; - -export class ContentSessionStore { - constructor(mm) { - if (Services.appinfo.sessionHistoryInParent) { - throw new Error("This frame script should not be loaded for SHIP"); - } - - this.mm = mm; - this.messageQueue = new MessageQueue(this); - - this.epoch = 0; - - this.contentRestoreInitialized = false; - - this.handlers = [ - this.messageQueue, - new EventListener(this), - new SessionHistoryListener(this), - ]; - - ChromeUtils.defineLazyGetter(this, "contentRestore", () => { - this.contentRestoreInitialized = true; - return new lazy.ContentRestore(mm); - }); - - MESSAGES.forEach(m => mm.addMessageListener(m, this)); - - mm.addEventListener("unload", this); - } - - receiveMessage({ name, data }) { - // The docShell might be gone. Don't process messages, - // that will just lead to errors anyway. - if (!this.mm.docShell) { - return; - } - - // A fresh tab always starts with epoch=0. The parent has the ability to - // override that to signal a new era in this tab's life. This enables it - // to ignore async messages that were already sent but not yet received - // and would otherwise confuse the internal tab state. - if (data && data.epoch && data.epoch != this.epoch) { - this.epoch = data.epoch; - } - - switch (name) { - case "SessionStore:restoreHistory": - this.restoreHistory(data); - break; - case "SessionStore:restoreTabContent": - this.restoreTabContent(data); - break; - case "SessionStore:resetRestore": - this.contentRestore.resetRestore(); - break; - case "SessionStore:flush": - this.flush(data); - break; - case "SessionStore:prepareForProcessChange": - // During normal in-process navigations, the DocShell would take - // care of automatically persisting layout history state to record - // scroll positions on the nsSHEntry. Unfortunately, process switching - // is not a normal navigation, so for now we do this ourselves. This - // is a workaround until session history state finally lives in the - // parent process. - this.mm.docShell.persistLayoutHistoryState(); - break; - default: - console.error("received unknown message '" + name + "'"); - break; - } - } - - // non-SHIP only - restoreHistory(data) { - let { epoch, tabData, loadArguments, isRemotenessUpdate } = data; - - this.contentRestore.restoreHistory(tabData, loadArguments, { - // Note: The callbacks passed here will only be used when a load starts - // that was not initiated by sessionstore itself. This can happen when - // some code calls browser.loadURI() or browser.reload() on a pending - // browser/tab. - - onLoadStarted: () => { - // Notify the parent that the tab is no longer pending. - this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { - epoch, - }); - }, - - onLoadFinished: () => { - // Tell SessionStore.sys.mjs that it may want to restore some more tabs, - // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. - this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { - epoch, - }); - }, - }); - - if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) { - // For non-remote tabs, when restoreHistory finishes, we send a synchronous - // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of - // SSTabRestoring seem to get confused if chrome and content are out of - // sync about the state of the restore (particularly regarding - // docShell.currentURI). Using a synchronous message is the easiest way - // to temporarily synchronize them. - // - // For remote tabs, because all nsIWebProgress notifications are sent - // asynchronously using messages, we get the same-order guarantees of the - // message manager, and can use an async message. - this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", { - epoch, - isRemotenessUpdate, - }); - } else { - this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", { - epoch, - isRemotenessUpdate, - }); - } - } - - restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) { - let epoch = this.epoch; - - // We need to pass the value of didStartLoad back to SessionStore.sys.mjs. - let didStartLoad = this.contentRestore.restoreTabContent( - loadArguments, - isRemotenessUpdate, - () => { - // Tell SessionStore.sys.mjs that it may want to restore some more tabs, - // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. - this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { - epoch, - isRemotenessUpdate, - }); - } - ); - - this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { - epoch, - isRemotenessUpdate, - reason, - }); - - if (!didStartLoad) { - // Pretend that the load succeeded so that event handlers fire correctly. - this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { - epoch, - isRemotenessUpdate, - }); - } - } - - flush({ id }) { - // Flush the message queue, send the latest updates. - this.messageQueue.send({ flushID: id }); - } - - handleEvent(event) { - if (event.type == "unload") { - this.onUnload(); - } - } - - onUnload() { - // Upon frameLoader destruction, send a final update message to - // the parent and flush all data currently held in the child. - this.messageQueue.send({ isFinal: true }); - - for (let handler of this.handlers) { - if (handler.uninit) { - handler.uninit(); - } - } - - if (this.contentRestoreInitialized) { - // Remove progress listeners. - this.contentRestore.resetRestore(); - } - - // We don't need to take care of any StateChangeNotifier observers as they - // will die with the content script. The same goes for the privacy transition - // observer that will die with the docShell when the tab is closed. - } -} diff --git a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs index 4d53b166c0..d0627180f0 100644 --- a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs +++ b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs @@ -182,7 +182,7 @@ export var RecentlyClosedTabsAndWindowsMenuUtils = { * @param aEvent * The command event when the user clicks the restore all menu item */ - onRestoreAllWindowsCommand(aEvent) { + onRestoreAllWindowsCommand() { const count = lazy.SessionStore.getClosedWindowCount(); for (let index = 0; index < count; index++) { lazy.SessionStore.undoCloseWindow(index); @@ -265,7 +265,7 @@ function createEntry( element.removeAttribute("oncommand"); element.addEventListener( "command", - event => { + () => { lazy.SessionStore.undoClosedTabFromClosedWindow( { sourceClosedId }, aClosedTab.closedId diff --git a/browser/components/sessionstore/SessionFile.sys.mjs b/browser/components/sessionstore/SessionFile.sys.mjs index 1e5a3bf718..077529d739 100644 --- a/browser/components/sessionstore/SessionFile.sys.mjs +++ b/browser/components/sessionstore/SessionFile.sys.mjs @@ -194,6 +194,7 @@ var SessionFileInternal = { }, async _readInternal(useOldExtension) { + Services.telemetry.setEventRecordingEnabled("session_restore", true); let result; let noFilesFound = true; this._usingOldExtension = useOldExtension; @@ -251,6 +252,18 @@ var SessionFileInternal = { path, ". Wrong format/version: " + JSON.stringify(parsed.version) + "." ); + Services.telemetry.recordEvent( + "session_restore", + "backup_can_be_loaded", + "session_file", + null, + { + can_load: "false", + path_key: key, + loadfail_reason: + "Wrong format/version: " + JSON.stringify(parsed.version) + ".", + } + ); continue; } result = { @@ -259,6 +272,17 @@ var SessionFileInternal = { parsed, useOldExtension, }; + Services.telemetry.recordEvent( + "session_restore", + "backup_can_be_loaded", + "session_file", + null, + { + can_load: "true", + path_key: key, + loadfail_reason: "N/A", + } + ); Services.telemetry .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") .add(false); @@ -269,6 +293,17 @@ var SessionFileInternal = { } catch (ex) { if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { exists = false; + Services.telemetry.recordEvent( + "session_restore", + "backup_can_be_loaded", + "session_file", + null, + { + can_load: "false", + path_key: key, + loadfail_reason: "File doesn't exist.", + } + ); } else if ( DOMException.isInstance(ex) && ex.name == "NotAllowedError" @@ -277,6 +312,17 @@ var SessionFileInternal = { // or similar failures. We'll just count it as "corrupted". console.error("Could not read session file ", ex); corrupted = true; + Services.telemetry.recordEvent( + "session_restore", + "backup_can_be_loaded", + "session_file", + null, + { + can_load: "false", + path_key: key, + loadfail_reason: ` ${ex.name}: Could not read session file`, + } + ); } else if (ex instanceof SyntaxError) { console.error( "Corrupt session file (invalid JSON found) ", @@ -285,6 +331,17 @@ var SessionFileInternal = { ); // File is corrupted, try next file corrupted = true; + Services.telemetry.recordEvent( + "session_restore", + "backup_can_be_loaded", + "session_file", + null, + { + can_load: "false", + path_key: key, + loadfail_reason: ` ${ex.name}: Corrupt session file (invalid JSON found)`, + } + ); } } finally { if (exists) { @@ -292,6 +349,17 @@ var SessionFileInternal = { Services.telemetry .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") .add(corrupted); + Services.telemetry.recordEvent( + "session_restore", + "backup_can_be_loaded", + "session_file", + null, + { + can_load: (!corrupted).toString(), + path_key: key, + loadfail_reason: "N/A", + } + ); } } } diff --git a/browser/components/sessionstore/SessionSaver.sys.mjs b/browser/components/sessionstore/SessionSaver.sys.mjs index 2f08bb2243..1237e3f970 100644 --- a/browser/components/sessionstore/SessionSaver.sys.mjs +++ b/browser/components/sessionstore/SessionSaver.sys.mjs @@ -210,7 +210,7 @@ var SessionSaverInternal = { /** * Observe idle/ active notifications. */ - observe(subject, topic, data) { + observe(subject, topic) { switch (topic) { case "idle": this._isIdle = true; diff --git a/browser/components/sessionstore/SessionStartup.sys.mjs b/browser/components/sessionstore/SessionStartup.sys.mjs index ff3ba55176..0d017ac035 100644 --- a/browser/components/sessionstore/SessionStartup.sys.mjs +++ b/browser/components/sessionstore/SessionStartup.sys.mjs @@ -158,6 +158,11 @@ export var SessionStartup = { */ _onSessionFileRead({ source, parsed, noFilesFound }) { this._initialized = true; + const crashReasons = { + FINAL_STATE_WRITING_INCOMPLETE: "final-state-write-incomplete", + SESSION_STATE_FLAG_MISSING: + "session-state-missing-or-running-at-last-write", + }; // Let observers modify the state before it is used let supportsStateString = this._createSupportsString(source); @@ -210,12 +215,17 @@ export var SessionStartup = { delete this._initialState.lastSessionState; } + let previousSessionCrashedReason = "N/A"; lazy.CrashMonitor.previousCheckpoints.then(checkpoints => { if (checkpoints) { // If the previous session finished writing the final state, we'll // assume there was no crash. this._previousSessionCrashed = !checkpoints["sessionstore-final-state-write-complete"]; + if (!checkpoints["sessionstore-final-state-write-complete"]) { + previousSessionCrashedReason = + crashReasons.FINAL_STATE_WRITING_INCOMPLETE; + } } else if (noFilesFound) { // If the Crash Monitor could not load a checkpoints file it will // provide null. This could occur on the first run after updating to @@ -241,6 +251,13 @@ export var SessionStartup = { this._previousSessionCrashed = !stateFlagPresent || this._initialState.session.state == STATE_RUNNING_STR; + if ( + !stateFlagPresent || + this._initialState.session.state == STATE_RUNNING_STR + ) { + previousSessionCrashedReason = + crashReasons.SESSION_STATE_FLAG_MISSING; + } } // Report shutdown success via telemetry. Shortcoming here are @@ -249,6 +266,16 @@ export var SessionStartup = { Services.telemetry .getHistogramById("SHUTDOWN_OK") .add(!this._previousSessionCrashed); + Services.telemetry.recordEvent( + "session_restore", + "shutdown_success", + "session_startup", + null, + { + shutdown_ok: this._previousSessionCrashed.toString(), + shutdown_reason: previousSessionCrashedReason, + } + ); Services.obs.addObserver(this, "sessionstore-windows-restored", true); @@ -268,7 +295,7 @@ export var SessionStartup = { /** * Handle notifications */ - observe(subject, topic, data) { + observe(subject, topic) { switch (topic) { case "sessionstore-windows-restored": Services.obs.removeObserver(this, "sessionstore-windows-restored"); diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs index f269251f54..16137b8388 100644 --- a/browser/components/sessionstore/SessionStore.sys.mjs +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -109,59 +109,6 @@ const WINDOW_OPEN_FEATURES_MAP = { statusbar: "status", }; -// Messages that will be received via the Frame Message Manager. -const MESSAGES = [ - // The content script sends us data that has been invalidated and needs to - // be saved to disk. - "SessionStore:update", - - // The restoreHistory code has run. This is a good time to run SSTabRestoring. - "SessionStore:restoreHistoryComplete", - - // The load for the restoring tab has begun. We update the URL bar at this - // time; if we did it before, the load would overwrite it. - "SessionStore:restoreTabContentStarted", - - // All network loads for a restoring tab are done, so we should - // consider restoring another tab in the queue. The document has - // been restored, and forms have been filled. We trigger - // SSTabRestored at this time. - "SessionStore:restoreTabContentComplete", - - // The content script encountered an error. - "SessionStore:error", -]; - -// The list of messages we accept from <xul:browser>s that have no tab -// assigned, or whose windows have gone away. Those are for example the -// ones that preload about:newtab pages, or from browsers where the window -// has just been closed. -const NOTAB_MESSAGES = new Set([ - // For a description see above. - "SessionStore:update", - - // For a description see above. - "SessionStore:error", -]); - -// The list of messages we accept without an "epoch" parameter. -// See getCurrentEpoch() and friends to find out what an "epoch" is. -const NOEPOCH_MESSAGES = new Set([ - // For a description see above. - "SessionStore:error", -]); - -// The list of messages we want to receive even during the short period after a -// frame has been removed from the DOM and before its frame script has finished -// unloading. -const CLOSED_MESSAGES = new Set([ - // For a description see above. - "SessionStore:update", - - // For a description see above. - "SessionStore:error", -]); - // These are tab events that we listen to. const TAB_EVENTS = [ "TabOpen", @@ -645,10 +592,6 @@ export var SessionStore = { SessionStoreInternal.deleteCustomGlobalValue(aKey); }, - persistTabAttribute: function ss_persistTabAttribute(aName) { - SessionStoreInternal.persistTabAttribute(aName); - }, - restoreLastSession: function ss_restoreLastSession() { SessionStoreInternal.restoreLastSession(); }, @@ -813,18 +756,6 @@ export var SessionStore = { }, /** - * Prepares to change the remoteness of the given browser, by ensuring that - * the local instance of session history is up-to-date. - */ - async prepareToChangeRemoteness(aTab) { - await SessionStoreInternal.prepareToChangeRemoteness(aTab); - }, - - finishTabRemotenessChange(aTab, aSwitchId) { - SessionStoreInternal.finishTabRemotenessChange(aTab, aSwitchId); - }, - - /** * Clear session store data for a given private browsing window. * @param {ChromeWindow} win - Open private browsing window to clear data for. */ @@ -1354,8 +1285,6 @@ var SessionStoreInternal = { "privacy.resistFingerprinting" ); Services.prefs.addObserver("privacy.resistFingerprinting", this); - - this._shistoryInParent = Services.appinfo.sessionHistoryInParent; }, /** @@ -1434,33 +1363,26 @@ var SessionStoreInternal = { } break; case "browsing-context-did-set-embedder": - if (Services.appinfo.sessionHistoryInParent) { - if ( - aSubject && - aSubject === aSubject.top && - aSubject.isContent && - aSubject.embedderElement && - aSubject.embedderElement.permanentKey - ) { - let permanentKey = aSubject.embedderElement.permanentKey; - this._browserSHistoryListener.get(permanentKey)?.unregister(); - this.getOrCreateSHistoryListener(permanentKey, aSubject, true); - } + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + let permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); } break; case "browsing-context-discarded": - if (Services.appinfo.sessionHistoryInParent) { - let permanentKey = aSubject?.embedderElement?.permanentKey; - if (permanentKey) { - this._browserSHistoryListener.get(permanentKey)?.unregister(); - } + let permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener.get(permanentKey)?.unregister(); } break; case "browser-shutdown-tabstate-updated": - if (Services.appinfo.sessionHistoryInParent) { - // Non-SHIP code calls this when the frame script is unloaded. - this.onFinalTabStateUpdateComplete(aSubject); - } + this.onFinalTabStateUpdateComplete(aSubject); this._notifyOfClosedObjectsChange(); break; } @@ -1573,10 +1495,6 @@ var SessionStoreInternal = { } } - if (!Services.appinfo.sessionHistoryInParent) { - throw new Error("This function should only be used with SHIP"); - } - if (!permanentKey || browsingContext !== browsingContext.top) { return null; } @@ -1691,29 +1609,27 @@ var SessionStoreInternal = { return; } - if (Services.appinfo.sessionHistoryInParent) { - let listener = this.getOrCreateSHistoryListener( - permanentKey, - browsingContext - ); + let listener = this.getOrCreateSHistoryListener( + permanentKey, + browsingContext + ); - if (listener) { - let historychange = - // If it is not the scheduled update (tab closed, window closed etc), - // try to store the loading non-web-controlled page opened in _blank - // first. - (forStorage && - lazy.SessionHistory.collectNonWebControlledBlankLoadingSession( - browsingContext - )) || - listener.collect(permanentKey, browsingContext, { - collectFull: !!update.sHistoryNeeded, - writeToCache: false, - }); + if (listener) { + let historychange = + // If it is not the scheduled update (tab closed, window closed etc), + // try to store the loading non-web-controlled page opened in _blank + // first. + (forStorage && + lazy.SessionHistory.collectNonWebControlledBlankLoadingSession( + browsingContext + )) || + listener.collect(permanentKey, browsingContext, { + collectFull: !!update.sHistoryNeeded, + writeToCache: false, + }); - if (historychange) { - update.data.historychange = historychange; - } + if (historychange) { + update.data.historychange = historychange; } } @@ -1724,98 +1640,6 @@ var SessionStoreInternal = { this.onTabStateUpdate(permanentKey, win, update); }, - /** - * This method handles incoming messages sent by the session store content - * script via the Frame Message Manager or Parent Process Message Manager, - * and thus enables communication with OOP tabs. - */ - receiveMessage(aMessage) { - if (Services.appinfo.sessionHistoryInParent) { - throw new Error( - `received unexpected message '${aMessage.name}' with ` + - `sessionHistoryInParent enabled` - ); - } - - // If we got here, that means we're dealing with a frame message - // manager message, so the target will be a <xul:browser>. - var browser = aMessage.target; - let win = browser.ownerGlobal; - let tab = win ? win.gBrowser.getTabForBrowser(browser) : null; - - // Ensure we receive only specific messages from <xul:browser>s that - // have no tab or window assigned, e.g. the ones that preload - // about:newtab pages, or windows that have closed. - if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { - throw new Error( - `received unexpected message '${aMessage.name}' ` + - `from a browser that has no tab or window` - ); - } - - let data = aMessage.data || {}; - let hasEpoch = data.hasOwnProperty("epoch"); - - // Most messages sent by frame scripts require to pass an epoch. - if (!hasEpoch && !NOEPOCH_MESSAGES.has(aMessage.name)) { - throw new Error(`received message '${aMessage.name}' without an epoch`); - } - - // Ignore messages from previous epochs. - if (hasEpoch && !this.isCurrentEpoch(browser.permanentKey, data.epoch)) { - return; - } - - switch (aMessage.name) { - case "SessionStore:update": - // |browser.frameLoader| might be empty if the browser was already - // destroyed and its tab removed. In that case we still have the last - // frameLoader we know about to compare. - let frameLoader = - browser.frameLoader || - this._lastKnownFrameLoader.get(browser.permanentKey); - - // If the message isn't targeting the latest frameLoader discard it. - if (frameLoader != aMessage.targetFrameLoader) { - return; - } - - this.onTabStateUpdate(browser.permanentKey, browser.ownerGlobal, data); - - // SHIP code will call this when it receives "browser-shutdown-tabstate-updated" - if (data.isFinal) { - if (!Services.appinfo.sessionHistoryInParent) { - this.onFinalTabStateUpdateComplete(browser); - } - } else if (data.flushID) { - // This is an update kicked off by an async flush request. Notify the - // TabStateFlusher so that it can finish the request and notify its - // consumer that's waiting for the flush to be done. - lazy.TabStateFlusher.resolve(browser, data.flushID); - } - - break; - case "SessionStore:restoreHistoryComplete": - this._restoreHistoryComplete(browser, data); - break; - case "SessionStore:restoreTabContentStarted": - this._restoreTabContentStarted(browser, data); - break; - case "SessionStore:restoreTabContentComplete": - this._restoreTabContentComplete(browser, data); - break; - case "SessionStore:error": - lazy.TabStateFlusher.resolveAll( - browser, - false, - "Received error from the content process" - ); - break; - default: - throw new Error(`received unknown message '${aMessage.name}'`); - } - }, - /* ........ Window Event Handlers .............. */ /** @@ -1917,21 +1741,6 @@ var SessionStoreInternal = { // internal data about the window. aWindow.__SSi = this._generateWindowID(); - if (!Services.appinfo.sessionHistoryInParent) { - let mm = aWindow.getGroupMessageManager("browsers"); - MESSAGES.forEach(msg => { - let listenWhenClosed = CLOSED_MESSAGES.has(msg); - mm.addMessageListener(msg, this, listenWhenClosed); - }); - - // Load the frame script after registering listeners. - mm.loadFrameScript( - "chrome://browser/content/content-sessionStore.js", - true, - true - ); - } - // and create its data object this._windows[aWindow.__SSi] = { tabs: [], @@ -2347,7 +2156,7 @@ var SessionStoreInternal = { // Save non-private windows if they have at // least one saveable tab or are the last window. if (!winData.isPrivate) { - this.maybeSaveClosedWindow(winData, isLastWindow, true); + this.maybeSaveClosedWindow(winData, isLastWindow); if (!isLastWindow && winData.closedId > -1) { this._addClosedAction( @@ -2402,11 +2211,6 @@ var SessionStoreInternal = { // Cache the window state until it is completely gone. DyingWindowCache.set(aWindow, winData); - if (!Services.appinfo.sessionHistoryInParent) { - let mm = aWindow.getGroupMessageManager("browsers"); - MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); - } - this._saveableClosedWindowData.delete(winData); delete aWindow.__SSi; }, @@ -2428,7 +2232,7 @@ var SessionStoreInternal = { * to call this method again asynchronously (for example, after * a window flush). */ - maybeSaveClosedWindow(winData, isLastWindow, recordTelemetry = false) { + maybeSaveClosedWindow(winData, isLastWindow) { // Make sure SessionStore is still running, and make sure that we // haven't chosen to forget this window. if ( @@ -2489,13 +2293,9 @@ var SessionStoreInternal = { this._removeClosedWindow(winIndex); return; } - // we only do this after the TabStateFlusher promise resolves in ssi_onClose - if (recordTelemetry) { - let closedTabsHistogram = Services.telemetry.getHistogramById( - "FX_SESSION_RESTORE_CLOSED_TABS_NOT_SAVED" - ); - closedTabsHistogram.add(winData._closedTabs.length); - } + this._log.warn( + `Discarding window with 0 saveable tabs and ${winData._closedTabs.length} closed tabs` + ); } } }, @@ -3644,7 +3444,7 @@ var SessionStoreInternal = { } // Create a new tab. - let userContextId = aTab.getAttribute("usercontextid"); + let userContextId = aTab.getAttribute("usercontextid") || ""; let tabOptions = { userContextId, @@ -4273,12 +4073,6 @@ var SessionStoreInternal = { this.saveStateDelayed(); }, - persistTabAttribute: function ssi_persistTabAttribute(aName) { - if (lazy.TabAttributes.persist(aName)) { - this.saveStateDelayed(); - } - }, - /** * Undoes the closing of a tab or window which corresponds * to the closedId passed in. @@ -5480,13 +5274,6 @@ var SessionStoreInternal = { tab.updateLastAccessed(tabData.lastAccessed); } - if ("attributes" in tabData) { - // Ensure that we persist tab attributes restored from previous sessions. - Object.keys(tabData.attributes).forEach(a => - lazy.TabAttributes.persist(a) - ); - } - if (!tabData.entries) { tabData.entries = []; } @@ -5656,7 +5443,6 @@ var SessionStoreInternal = { let browser = aTab.linkedBrowser; let window = aTab.ownerGlobal; - let tabbrowser = window.gBrowser; let tabData = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); let activeIndex = tabData.index - 1; let activePageData = tabData.entries[activeIndex] || null; @@ -5664,36 +5450,9 @@ var SessionStoreInternal = { this.markTabAsRestoring(aTab); - let isRemotenessUpdate = aOptions.isRemotenessUpdate; - let explicitlyUpdateRemoteness = !Services.appinfo.sessionHistoryInParent; - // If we aren't already updating the browser's remoteness, check if it's - // necessary. - if (explicitlyUpdateRemoteness && !isRemotenessUpdate) { - isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL( - browser, - uri - ); - - if (isRemotenessUpdate) { - // We updated the remoteness, so we need to send the history down again. - // - // Start a new epoch to discard all frame script messages relating to a - // previous epoch. All async messages that are still on their way to chrome - // will be ignored and don't override any tab data set when restoring. - let epoch = this.startNextEpoch(browser.permanentKey); - - this._sendRestoreHistory(browser, { - tabData, - epoch, - loadArguments, - isRemotenessUpdate, - }); - } - } - this._sendRestoreTabContent(browser, { loadArguments, - isRemotenessUpdate, + isRemotenessUpdate: aOptions.isRemotenessUpdate, reason: aOptions.restoreContentReason || RESTORE_TAB_CONTENT_REASON.SET_STATE, }); @@ -6828,10 +6587,8 @@ var SessionStoreInternal = { // The browser is no longer in any sort of restoring state. TAB_STATE_FOR_BROWSER.delete(browser); - if (Services.appinfo.sessionHistoryInParent) { - this._restoreListeners.get(browser.permanentKey)?.unregister(); - browser.browsingContext.clearRestoreState(); - } + this._restoreListeners.get(browser.permanentKey)?.unregister(); + browser.browsingContext.clearRestoreState(); aTab.removeAttribute("pending"); @@ -6855,9 +6612,6 @@ var SessionStoreInternal = { return; } - if (!Services.appinfo.sessionHistoryInParent) { - browser.messageManager.sendAsyncMessage("SessionStore:resetRestore", {}); - } this._resetLocalTabRestoringState(tab); }, @@ -7048,7 +6802,7 @@ var SessionStoreInternal = { } catch {} // May have already gotten rid of the browser's webProgress. }, - onStateChange(webProgress, request, stateFlags, status) { + onStateChange(webProgress, request, stateFlags) { if ( webProgress.isTopLevel && stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && @@ -7109,7 +6863,7 @@ var SessionStoreInternal = { OnHistoryPurge() {}, OnHistoryReplaceEntry() {}, - onStateChange(webProgress, request, stateFlags, status) { + onStateChange(webProgress, request, stateFlags) { if ( webProgress.isTopLevel && stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && @@ -7143,10 +6897,6 @@ var SessionStoreInternal = { * history restores. */ _restoreHistory(browser, data) { - if (!Services.appinfo.sessionHistoryInParent) { - throw new Error("This function should only be used with SHIP"); - } - this._tabStateToRestore.set(browser.permanentKey, data); // In case about:blank isn't done yet. @@ -7190,7 +6940,7 @@ var SessionStoreInternal = { this._tabStateRestorePromises.delete(browser.permanentKey); - this._restoreHistoryComplete(browser, data); + this._restoreHistoryComplete(browser); }; promise.then(onResolve).catch(() => {}); @@ -7238,10 +6988,6 @@ var SessionStoreInternal = { * history restores. */ _restoreTabContent(browser, options = {}) { - if (!Services.appinfo.sessionHistoryInParent) { - throw new Error("This function should only be used with SHIP"); - } - this._restoreListeners.get(browser.permanentKey)?.unregister(); this._restoreTabContentStarted(browser, options); @@ -7266,17 +7012,10 @@ var SessionStoreInternal = { }, _sendRestoreTabContent(browser, options) { - if (Services.appinfo.sessionHistoryInParent) { - this._restoreTabContent(browser, options); - } else { - browser.messageManager.sendAsyncMessage( - "SessionStore:restoreTabContent", - options - ); - } + this._restoreTabContent(browser, options); }, - _restoreHistoryComplete(browser, data) { + _restoreHistoryComplete(browser) { let win = browser.ownerGlobal; let tab = win?.gBrowser.getTabForBrowser(browser); if (!tab) { @@ -7417,68 +7156,12 @@ var SessionStoreInternal = { delete options.tabData.storage; } - if (Services.appinfo.sessionHistoryInParent) { - this._restoreHistory(browser, options); - } else { - browser.messageManager.sendAsyncMessage( - "SessionStore:restoreHistory", - options - ); - } + this._restoreHistory(browser, options); if (browser && browser.frameLoader) { browser.frameLoader.requestEpochUpdate(options.epoch); } }, - - // Flush out session history state so that it can be used to restore the state - // into a new process in `finishTabRemotenessChange`. - // - // NOTE: This codepath is temporary while the Fission Session History rewrite - // is in process, and will be removed & replaced once that rewrite is - // complete. (bug 1645062) - async prepareToChangeRemoteness(aBrowser) { - aBrowser.messageManager.sendAsyncMessage( - "SessionStore:prepareForProcessChange" - ); - await lazy.TabStateFlusher.flush(aBrowser); - }, - - // Handle finishing the remoteness change for a tab by restoring session - // history state into it, and resuming the ongoing network load. - // - // NOTE: This codepath is temporary while the Fission Session History rewrite - // is in process, and will be removed & replaced once that rewrite is - // complete. (bug 1645062) - finishTabRemotenessChange(aTab, aSwitchId) { - let window = aTab.ownerGlobal; - if (!window || !window.__SSi || window.closed) { - return; - } - - let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); - let options = { - restoreImmediately: true, - restoreContentReason: RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE, - isRemotenessUpdate: true, - loadArguments: { - redirectLoadSwitchId: aSwitchId, - // As we're resuming a load which has been redirected from another - // process, record the history index which is currently being requested. - // It has to be offset by 1 to get back to native history indices from - // SessionStore history indicies. - redirectHistoryIndex: tabState.requestedIndex - 1, - }, - }; - - // Need to reset restoring tabs. - if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { - this._resetLocalTabRestoringState(aTab); - } - - // Restore the state into the tab. - this.restoreTab(aTab, tabState, options); - }, }; /** @@ -7689,7 +7372,7 @@ var DirtyWindows = { this._data.delete(window); }, - clear(window) { + clear(_window) { this._data = new WeakMap(); }, }; diff --git a/browser/components/sessionstore/StartupPerformance.sys.mjs b/browser/components/sessionstore/StartupPerformance.sys.mjs index a13333d9d1..c2b791609b 100644 --- a/browser/components/sessionstore/StartupPerformance.sys.mjs +++ b/browser/components/sessionstore/StartupPerformance.sys.mjs @@ -153,7 +153,7 @@ export var StartupPerformance = { }, COLLECT_RESULTS_AFTER_MS); }, - observe(subject, topic, details) { + observe(subject, topic) { try { switch (topic) { case "sessionstore-restoring-on-startup": diff --git a/browser/components/sessionstore/TabAttributes.sys.mjs b/browser/components/sessionstore/TabAttributes.sys.mjs index 1c7f54b6ab..ea53156d12 100644 --- a/browser/components/sessionstore/TabAttributes.sys.mjs +++ b/browser/components/sessionstore/TabAttributes.sys.mjs @@ -2,27 +2,13 @@ * 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/. */ -// We never want to directly read or write these attributes. -// 'image' should not be accessed directly but handled by using the -// gBrowser.getIcon()/setIcon() methods. -// 'muted' should not be accessed directly but handled by using the -// tab.linkedBrowser.audioMuted/toggleMuteAudio methods. -// 'pending' is used internal by sessionstore and managed accordingly. -const ATTRIBUTES_TO_SKIP = new Set([ - "image", - "muted", - "pending", - "skipbackgroundnotify", -]); +// Tab attributes which are persisted & restored by SessionStore. +const PERSISTED_ATTRIBUTES = ["customizemode"]; // A set of tab attributes to persist. We will read a given list of tab // attributes when collecting tab data and will re-set those attributes when // the given tab data is restored to a new tab. export var TabAttributes = Object.freeze({ - persist(name) { - return TabAttributesInternal.persist(name); - }, - get(tab) { return TabAttributesInternal.get(tab); }, @@ -33,21 +19,10 @@ export var TabAttributes = Object.freeze({ }); var TabAttributesInternal = { - _attrs: new Set(), - - persist(name) { - if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { - return false; - } - - this._attrs.add(name); - return true; - }, - get(tab) { let data = {}; - for (let name of this._attrs) { + for (let name of PERSISTED_ATTRIBUTES) { if (tab.hasAttribute(name)) { data[name] = tab.getAttribute(name); } @@ -57,15 +32,11 @@ var TabAttributesInternal = { }, set(tab, data = {}) { - // Clear attributes. - for (let name of this._attrs) { + // Clear & Set attributes. + for (let name of PERSISTED_ATTRIBUTES) { tab.removeAttribute(name); - } - - // Set attributes. - for (let [name, value] of Object.entries(data)) { - if (!ATTRIBUTES_TO_SKIP.has(name)) { - tab.setAttribute(name, value); + if (name in data) { + tab.setAttribute(name, data[name]); } } }, diff --git a/browser/components/sessionstore/TabStateFlusher.sys.mjs b/browser/components/sessionstore/TabStateFlusher.sys.mjs index e391abc970..ed7953e41e 100644 --- a/browser/components/sessionstore/TabStateFlusher.sys.mjs +++ b/browser/components/sessionstore/TabStateFlusher.sys.mjs @@ -2,11 +2,6 @@ * 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/. */ -const lazy = {}; -ChromeUtils.defineESModuleGetters(lazy, { - SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", -}); - /** * A module that enables async flushes. Updates from frame scripts are * throttled to be sent only once per second. If an action wants a tab's latest @@ -33,23 +28,6 @@ export var TabStateFlusher = Object.freeze({ }, /** - * Resolves the flush request with the given flush ID. - * - * @param browser (<xul:browser>) - * The browser for which the flush is being resolved. - * @param flushID (int) - * The ID of the flush that was sent to the browser. - * @param success (bool, optional) - * Whether or not the flush succeeded. - * @param message (string, optional) - * An error message that will be sent to the Console in the - * event that a flush failed. - */ - resolve(browser, flushID, success = true, message = "") { - TabStateFlusherInternal.resolve(browser, flushID, success, message); - }, - - /** * Resolves all active flush requests for a given browser. This should be * used when the content process crashed or the final update message was * seen. In those cases we can't guarantee to ever hear back from the frame @@ -69,9 +47,6 @@ export var TabStateFlusher = Object.freeze({ }); var TabStateFlusherInternal = { - // Stores the last request ID. - _lastRequestID: 0, - // A map storing all active requests per browser. A request is a // triple of a map containing all flush requests, a promise that // resolve when a request for a browser is canceled, and the @@ -79,7 +54,6 @@ var TabStateFlusherInternal = { _requests: new WeakMap(), initEntry(entry) { - entry.perBrowserRequests = new Map(); entry.cancelPromise = new Promise(resolve => { entry.cancel = resolve; }).then(result => { @@ -96,7 +70,6 @@ var TabStateFlusherInternal = { * all the latest data. */ flush(browser) { - let id = ++this._lastRequestID; let nativePromise = Promise.resolve(); if (browser && browser.frameLoader) { /* @@ -106,24 +79,6 @@ var TabStateFlusherInternal = { nativePromise = browser.frameLoader.requestTabStateFlush(); } - if (!Services.appinfo.sessionHistoryInParent) { - /* - In the event that we have to trigger a process switch and thus change - browser remoteness, session store needs to register and track the new - browser window loaded and to have message manager listener registered - ** before ** TabStateFlusher send "SessionStore:flush" message. This fixes - the race where we send the message before the message listener is - registered for it. - */ - lazy.SessionStore.ensureInitialized(browser.ownerGlobal); - - let mm = browser.messageManager; - mm.sendAsyncMessage("SessionStore:flush", { - id, - epoch: lazy.SessionStore.getCurrentEpoch(browser), - }); - } - // Retrieve active requests for given browser. let permanentKey = browser.permanentKey; let request = this._requests.get(permanentKey); @@ -134,22 +89,10 @@ var TabStateFlusherInternal = { this._requests.set(permanentKey, request); } - // Non-SHIP flushes resolve this after the "SessionStore:update" message. We - // don't use that message for SHIP, so it's fine to resolve the request - // immediately after the native promise resolves, since SessionStore will - // have processed all updates from this browser by that point. - let requestPromise = Promise.resolve(); - if (!Services.appinfo.sessionHistoryInParent) { - requestPromise = new Promise(resolve => { - // Store resolve() so that we can resolve the promise later. - request.perBrowserRequests.set(id, resolve); - }); - } - - return Promise.race([ - nativePromise.then(_ => requestPromise), - request.cancelPromise, - ]); + // It's fine to resolve the request immediately after the native promise + // resolves, since SessionStore will have processed all updates from this + // browser by that point. + return Promise.race([nativePromise, request.cancelPromise]); }, /** @@ -167,41 +110,6 @@ var TabStateFlusherInternal = { }, /** - * Resolves the flush request with the given flush ID. - * - * @param browser (<xul:browser>) - * The browser for which the flush is being resolved. - * @param flushID (int) - * The ID of the flush that was sent to the browser. - * @param success (bool, optional) - * Whether or not the flush succeeded. - * @param message (string, optional) - * An error message that will be sent to the Console in the - * event that a flush failed. - */ - resolve(browser, flushID, success = true, message = "") { - // Nothing to do if there are no pending flushes for the given browser. - if (!this._requests.has(browser.permanentKey)) { - return; - } - - // Retrieve active requests for given browser. - let { perBrowserRequests } = this._requests.get(browser.permanentKey); - if (!perBrowserRequests.has(flushID)) { - return; - } - - if (!success) { - console.error("Failed to flush browser: ", message); - } - - // Resolve the request with the given id. - let resolve = perBrowserRequests.get(flushID); - perBrowserRequests.delete(flushID); - resolve(success); - }, - - /** * Resolves all active flush requests for a given browser. This should be * used when the content process crashed or the final update message was * seen. In those cases we can't guarantee to ever hear back from the frame diff --git a/browser/components/sessionstore/content/aboutSessionRestore.js b/browser/components/sessionstore/content/aboutSessionRestore.js index 51bed7c51b..2dfa45d40f 100644 --- a/browser/components/sessionstore/content/aboutSessionRestore.js +++ b/browser/components/sessionstore/content/aboutSessionRestore.js @@ -204,7 +204,7 @@ function startNewSession() { ), }); } else { - getBrowserWindow().BrowserHome(); + getBrowserWindow().BrowserCommands.home(); } } @@ -325,31 +325,31 @@ var treeView = { setTree(treeBox) { this.treeBox = treeBox; }, - getCellText(idx, column) { + getCellText(idx) { return gTreeData[idx].label; }, isContainer(idx) { return "open" in gTreeData[idx]; }, - getCellValue(idx, column) { + getCellValue(idx) { return gTreeData[idx].checked; }, isContainerOpen(idx) { return gTreeData[idx].open; }, - isContainerEmpty(idx) { + isContainerEmpty() { return false; }, - isSeparator(idx) { + isSeparator() { return false; }, isSorted() { return false; }, - isEditable(idx, column) { + isEditable() { return false; }, - canDrop(idx, orientation, dt) { + canDrop() { return false; }, getLevel(idx) { @@ -438,10 +438,10 @@ var treeView = { return null; }, - cycleHeader(column) {}, - cycleCell(idx, column) {}, + cycleHeader() {}, + cycleCell() {}, selectionChanged() {}, - getColumnProperties(column) { + getColumnProperties() { return ""; }, }; diff --git a/browser/components/sessionstore/content/content-sessionStore.js b/browser/components/sessionstore/content/content-sessionStore.js deleted file mode 100644 index a4bdea0bdc..0000000000 --- a/browser/components/sessionstore/content/content-sessionStore.js +++ /dev/null @@ -1,13 +0,0 @@ -/* 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/. */ - -/* eslint-env mozilla/frame-script */ - -"use strict"; - -const { ContentSessionStore } = ChromeUtils.importESModule( - "resource:///modules/sessionstore/ContentSessionStore.sys.mjs" -); - -void new ContentSessionStore(this); diff --git a/browser/components/sessionstore/jar.mn b/browser/components/sessionstore/jar.mn index 7e5bc07dc6..b31a4fb351 100644 --- a/browser/components/sessionstore/jar.mn +++ b/browser/components/sessionstore/jar.mn @@ -5,4 +5,3 @@ browser.jar: * content/browser/aboutSessionRestore.xhtml (content/aboutSessionRestore.xhtml) content/browser/aboutSessionRestore.js (content/aboutSessionRestore.js) - content/browser/content-sessionStore.js (content/content-sessionStore.js) diff --git a/browser/components/sessionstore/moz.build b/browser/components/sessionstore/moz.build index 1536826733..cd3a0ad6fc 100644 --- a/browser/components/sessionstore/moz.build +++ b/browser/components/sessionstore/moz.build @@ -5,14 +5,12 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] -BROWSER_CHROME_MANIFESTS += ["test/browser.toml"] +BROWSER_CHROME_MANIFESTS += ["test/browser.toml", "test/browser_oldformat.toml"] MARIONETTE_MANIFESTS += ["test/marionette/manifest.toml"] JAR_MANIFESTS += ["jar.mn"] EXTRA_JS_MODULES.sessionstore = [ - "ContentRestore.sys.mjs", - "ContentSessionStore.sys.mjs", "GlobalState.sys.mjs", "RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs", "RunState.sys.mjs", diff --git a/browser/components/sessionstore/test/SessionStoreTestUtils.sys.mjs b/browser/components/sessionstore/test/SessionStoreTestUtils.sys.mjs index dd2885cee4..eecb1240e2 100644 --- a/browser/components/sessionstore/test/SessionStoreTestUtils.sys.mjs +++ b/browser/components/sessionstore/test/SessionStoreTestUtils.sys.mjs @@ -100,7 +100,7 @@ export var SessionStoreTestUtils = { expectedTabsRestored = aState.windows.length; } - function onSSTabRestored(aEvent) { + function onSSTabRestored() { if (++tabsRestored == expectedTabsRestored) { // Remove the event listener from each window windows.forEach(function (win) { @@ -118,7 +118,7 @@ export var SessionStoreTestUtils = { // Used to add our listener to further windows so we can catch SSTabRestored // coming from them when creating a multi-window state. - function windowObserver(aSubject, aTopic, aData) { + function windowObserver(aSubject, aTopic) { if (aTopic == "domwindowopened") { let newWindow = aSubject; newWindow.addEventListener( diff --git a/browser/components/sessionstore/test/browser.toml b/browser/components/sessionstore/test/browser.toml index 26fb4b4550..7d5b407d22 100644 --- a/browser/components/sessionstore/test/browser.toml +++ b/browser/components/sessionstore/test/browser.toml @@ -22,30 +22,11 @@ support-files = [ "browser_scrollPositions_readerModeArticle.html", "browser_sessionStorage.html", "browser_speculative_connect.html", - "browser_248970_b_sample.html", - "browser_339445_sample.html", - "browser_423132_sample.html", - "browser_447951_sample.html", - "browser_454908_sample.html", - "browser_456342_sample.xhtml", - "browser_463205_sample.html", - "browser_463206_sample.html", - "browser_466937_sample.html", - "browser_485482_sample.html", - "browser_637020_slow.sjs", - "browser_662743_sample.html", - "browser_739531_sample.html", - "browser_739531_frame.html", - "browser_911547_sample.html", - "browser_911547_sample.html^headers^", "coopHeaderCommon.sjs", "restore_redirect_http.html", "restore_redirect_http.html^headers^", "restore_redirect_js.html", "restore_redirect_target.html", - "browser_1234021_page.html", - "browser_1284886_suspend_tab.html", - "browser_1284886_suspend_tab_2.html", "empty.html", "coop_coep.html", "coop_coep.html^headers^", @@ -58,248 +39,6 @@ prefs = [ "browser.sessionstore.closedTabsFromClosedWindows=true", ] -#NB: the following are disabled -# browser_464620_a.html -# browser_464620_b.html -# browser_464620_xd.html - -#disabled-for-intermittent-failures--bug-766044, browser_459906_empty.html -#disabled-for-intermittent-failures--bug-766044, browser_459906_sample.html -#disabled-for-intermittent-failures--bug-765389, browser_461743_sample.html - -["browser_1234021.js"] - -["browser_1284886_suspend_tab.js"] - -["browser_1446343-windowsize.js"] -skip-if = ["os == 'linux'"] # Bug 1600180 - -["browser_248970_b_perwindowpb.js"] -# Disabled because of leaks. -# Re-enabling and rewriting this test is tracked in bug 936919. -skip-if = ["true"] - -["browser_339445.js"] - -["browser_345898.js"] - -["browser_350525.js"] - -["browser_354894_perwindowpb.js"] - -["browser_367052.js"] - -["browser_393716.js"] -skip-if = ["debug"] # Bug 1507747 - -["browser_394759_basic.js"] -# Disabled for intermittent failures, bug 944372. -skip-if = ["true"] - -["browser_394759_behavior.js"] -https_first_disabled = true - -["browser_394759_perwindowpb.js"] - -["browser_394759_purge.js"] - -["browser_423132.js"] - -["browser_447951.js"] - -["browser_454908.js"] - -["browser_456342.js"] - -["browser_461634.js"] - -["browser_463205.js"] - -["browser_463206.js"] - -["browser_464199.js"] -# Disabled for frequent intermittent failures - -["browser_464620_a.js"] -skip-if = ["true"] - -["browser_464620_b.js"] -skip-if = ["true"] - -["browser_465215.js"] - -["browser_465223.js"] - -["browser_466937.js"] - -["browser_467409-backslashplosion.js"] - -["browser_477657.js"] -skip-if = ["os == 'linux' && os_version == '18.04'"] # bug 1610668 for ubuntu 18.04 - -["browser_480893.js"] - -["browser_485482.js"] - -["browser_485563.js"] - -["browser_490040.js"] - -["browser_491168.js"] - -["browser_491577.js"] -skip-if = [ - "verify && debug && os == 'mac'", - "verify && debug && os == 'win'", -] - -["browser_495495.js"] - -["browser_500328.js"] - -["browser_514751.js"] - -["browser_522375.js"] - -["browser_522545.js"] -skip-if = ["true"] # Bug 1380968 - -["browser_524745.js"] -skip-if = [ - "win10_2009 && !ccov", # Bug 1418627 - "os == 'linux'", # Bug 1803187 -] - -["browser_528776.js"] - -["browser_579868.js"] - -["browser_579879.js"] -skip-if = ["os == 'linux' && (debug || asan)"] # Bug 1234404 - -["browser_581937.js"] - -["browser_586068-apptabs.js"] - -["browser_586068-apptabs_ondemand.js"] -skip-if = ["verify && (os == 'mac' || os == 'win')"] - -["browser_586068-browser_state_interrupted.js"] - -["browser_586068-cascade.js"] - -["browser_586068-multi_window.js"] - -["browser_586068-reload.js"] -https_first_disabled = true - -["browser_586068-select.js"] - -["browser_586068-window_state.js"] - -["browser_586068-window_state_override.js"] - -["browser_586147.js"] - -["browser_588426.js"] - -["browser_590268.js"] - -["browser_590563.js"] - -["browser_595601-restore_hidden.js"] - -["browser_597071.js"] -skip-if = ["true"] # Needs to be rewritten as Marionette test, bug 995916 - -["browser_600545.js"] - -["browser_601955.js"] - -["browser_607016.js"] - -["browser_615394-SSWindowState_events_duplicateTab.js"] - -["browser_615394-SSWindowState_events_setBrowserState.js"] -skip-if = ["verify && debug && os == 'mac'"] - -["browser_615394-SSWindowState_events_setTabState.js"] - -["browser_615394-SSWindowState_events_setWindowState.js"] -https_first_disabled = true - -["browser_615394-SSWindowState_events_undoCloseTab.js"] - -["browser_615394-SSWindowState_events_undoCloseWindow.js"] -skip-if = [ - "os == 'win' && !debug", # Bug 1572554 - "os == 'linux'", # Bug 1572554 -] - -["browser_618151.js"] - -["browser_623779.js"] - -["browser_624727.js"] - -["browser_625016.js"] -skip-if = [ - "os == 'mac'", # Disabled on OS X: - "os == 'linux'", # linux, Bug 1348583 - "os == 'win' && debug", # Bug 1430977 -] - -["browser_628270.js"] - -["browser_635418.js"] - -["browser_636279.js"] - -["browser_637020.js"] - -["browser_645428.js"] - -["browser_659591.js"] - -["browser_662743.js"] - -["browser_662812.js"] -skip-if = ["verify"] - -["browser_665702-state_session.js"] - -["browser_682507.js"] - -["browser_687710.js"] - -["browser_687710_2.js"] -https_first_disabled = true - -["browser_694378.js"] - -["browser_701377.js"] -skip-if = [ - "verify && debug && os == 'win'", - "verify && debug && os == 'mac'", -] - -["browser_705597.js"] - -["browser_707862.js"] - -["browser_739531.js"] - -["browser_739805.js"] - -["browser_819510_perwindowpb.js"] -skip-if = ["true"] # Bug 1284312, Bug 1341980, bug 1381451 - -["browser_906076_lazy_tabs.js"] -https_first_disabled = true -skip-if = ["os == 'linux' && os_version == '18.04'"] # bug 1446464 - -["browser_911547.js"] - ["browser_aboutPrivateBrowsing.js"] ["browser_aboutSessionRestore.js"] @@ -316,7 +55,6 @@ support-files = ["file_async_flushes.html"] run-if = ["crashreporter"] ["browser_async_remove_tab.js"] -skip-if = ["!sessionHistoryInParent"] ["browser_async_window_flushing.js"] https_first_disabled = true @@ -393,6 +131,7 @@ https_first_disabled = true skip-if = ["verify && debug"] ["browser_formdata_cc.js"] +skip-if = ["asan"] # test runs too long ["browser_formdata_face.js"] @@ -466,12 +205,12 @@ skip-if = [ ["browser_privatetabs.js"] ["browser_purge_shistory.js"] -skip-if = ["!sessionHistoryInParent"] # Bug 1271024 ["browser_remoteness_flip_on_restore.js"] ["browser_reopen_all_windows.js"] https_first_disabled = true +skip-if = ["asan"] # high memory ["browser_replace_load.js"] skip-if = ["true"] # Bug 1646894 @@ -516,9 +255,6 @@ skip-if = [ ["browser_scrollPositionsReaderMode.js"] -["browser_send_async_message_oom.js"] -skip-if = ["sessionHistoryInParent"] # Tests that the frame script OOMs, which is unused when SHIP is enabled. - ["browser_sessionHistory.js"] https_first_disabled = true support-files = ["file_sessionHistory_hashchange.html"] diff --git a/browser/components/sessionstore/test/browser_354894_perwindowpb.js b/browser/components/sessionstore/test/browser_354894_perwindowpb.js index 90368536dc..30a065c1af 100644 --- a/browser/components/sessionstore/test/browser_354894_perwindowpb.js +++ b/browser/components/sessionstore/test/browser_354894_perwindowpb.js @@ -21,7 +21,7 @@ * not enabled on that platform (platform shim; the application is kept running * although there are no windows left) * @note There is a difference when closing a browser window with - * BrowserTryToCloseWindow() as opposed to close(). The former will make + * BrowserCommands.tryToCloseWindow() as opposed to close(). The former will make * nsSessionStore restore a window next time it gets a chance and will post * notifications. The latter won't. */ @@ -133,7 +133,7 @@ let setupTest = async function (options, testFunction) { * Helper: Will observe and handle the notifications for us */ let hitCount = 0; - function observer(aCancel, aTopic, aData) { + function observer(aCancel, aTopic) { // count so that we later may compare observing[aTopic]++; @@ -182,7 +182,7 @@ function injectTestTabs(win) { } /** - * Attempts to close a window via BrowserTryToCloseWindow so that + * Attempts to close a window via BrowserCommands.tryToCloseWindow so that * we get the browser-lastwindow-close-requested and * browser-lastwindow-close-granted observer notifications. * @@ -195,7 +195,7 @@ function injectTestTabs(win) { function closeWindowForRestoration(win) { return new Promise(resolve => { let closePromise = BrowserTestUtils.windowClosed(win); - win.BrowserTryToCloseWindow(); + win.BrowserCommands.tryToCloseWindow(); if (!win.closed) { resolve(false); return; @@ -415,7 +415,7 @@ add_task(async function test_open_close_restore_from_popup() { return; } - await setupTest({}, async function (newWin, obs) { + await setupTest({}, async function (newWin) { let newWin2 = await promiseNewWindowLoaded(); await injectTestTabs(newWin2); diff --git a/browser/components/sessionstore/test/browser_394759_basic.js b/browser/components/sessionstore/test/browser_394759_basic.js index 62d5c40e17..cc1c335165 100644 --- a/browser/components/sessionstore/test/browser_394759_basic.js +++ b/browser/components/sessionstore/test/browser_394759_basic.js @@ -74,7 +74,7 @@ function test() { let expectedTabs = data[0].tabs.length; newWin2.addEventListener( "SSTabRestored", - function sstabrestoredListener(aEvent) { + function sstabrestoredListener() { ++restoredTabs; info("Restored tab " + restoredTabs + "/" + expectedTabs); if (restoredTabs < expectedTabs) { diff --git a/browser/components/sessionstore/test/browser_394759_behavior.js b/browser/components/sessionstore/test/browser_394759_behavior.js index ee4b121e84..01217f86c9 100644 --- a/browser/components/sessionstore/test/browser_394759_behavior.js +++ b/browser/components/sessionstore/test/browser_394759_behavior.js @@ -34,7 +34,7 @@ function testWindows(windowsToOpen, expectedResults) { } let closedWindowData = ss.getClosedWindowData(); - let numPopups = closedWindowData.filter(function (el, i, arr) { + let numPopups = closedWindowData.filter(function (el) { return el.isPopup; }).length; let numNormal = ss.getClosedWindowCount() - numPopups; @@ -50,7 +50,7 @@ function testWindows(windowsToOpen, expectedResults) { is( numNormal, oResults.normal, - "There were " + oResults.normal + " normal windows to repoen" + "There were " + oResults.normal + " normal windows to reopen" ); })(); } @@ -63,14 +63,15 @@ add_task(async function test_closed_window_states() { let windowsToOpen = [ { isPopup: false }, - { isPopup: false }, + { isPopup: true }, + { isPopup: true }, { isPopup: true }, { isPopup: true }, { isPopup: true }, ]; let expectedResults = { - mac: { popup: 3, normal: 0 }, - other: { popup: 3, normal: 1 }, + mac: { popup: 5, normal: 0 }, + other: { popup: 5, normal: 1 }, }; await testWindows(windowsToOpen, expectedResults); @@ -81,10 +82,11 @@ add_task(async function test_closed_window_states() { { isPopup: false }, { isPopup: false }, { isPopup: false }, + { isPopup: false }, ]; let expectedResults2 = { - mac: { popup: 0, normal: 3 }, - other: { popup: 0, normal: 3 }, + mac: { popup: 0, normal: 5 }, + other: { popup: 0, normal: 5 }, }; await testWindows(windowsToOpen2, expectedResults2); diff --git a/browser/components/sessionstore/test/browser_394759_purge.js b/browser/components/sessionstore/test/browser_394759_purge.js index e5218c9936..ea75d6e4b2 100644 --- a/browser/components/sessionstore/test/browser_394759_purge.js +++ b/browser/components/sessionstore/test/browser_394759_purge.js @@ -9,7 +9,7 @@ let { ForgetAboutSite } = ChromeUtils.importESModule( function promiseClearHistory() { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe() { Services.obs.removeObserver( this, "browser:purge-session-history-for-domain" diff --git a/browser/components/sessionstore/test/browser_459906.js b/browser/components/sessionstore/test/browser_459906.js index 6827f6ad1d..5a0c1aeea3 100644 --- a/browser/components/sessionstore/test/browser_459906.js +++ b/browser/components/sessionstore/test/browser_459906.js @@ -17,7 +17,7 @@ function test() { let tab = BrowserTestUtils.addTab(gBrowser, testURL); tab.linkedBrowser.addEventListener( "load", - function listener(aEvent) { + function listener() { // wait for all frames to load completely if (frameCount++ < 2) { return; @@ -31,7 +31,7 @@ function test() { let tab2 = gBrowser.duplicateTab(tab); tab2.linkedBrowser.addEventListener( "load", - function loadListener(eventTab2) { + function loadListener() { // wait for all frames to load (and reload!) completely if (frameCount++ < 2) { return; diff --git a/browser/components/sessionstore/test/browser_461743.js b/browser/components/sessionstore/test/browser_461743.js index fd4501b5ac..a27ccc7721 100644 --- a/browser/components/sessionstore/test/browser_461743.js +++ b/browser/components/sessionstore/test/browser_461743.js @@ -24,7 +24,7 @@ function test() { let tab2 = gBrowser.duplicateTab(tab); tab2.linkedBrowser.addEventListener( "461743", - function listener(eventTab2) { + function listener() { tab2.linkedBrowser.removeEventListener("461743", listener, true); is(aEvent.data, "done", "XSS injection was attempted"); diff --git a/browser/components/sessionstore/test/browser_464199.js b/browser/components/sessionstore/test/browser_464199.js index 4ac8fba1a5..98a17c4955 100644 --- a/browser/components/sessionstore/test/browser_464199.js +++ b/browser/components/sessionstore/test/browser_464199.js @@ -9,7 +9,7 @@ let { ForgetAboutSite } = ChromeUtils.importESModule( function promiseClearHistory() { return new Promise(resolve => { let observer = { - observe(aSubject, aTopic, aData) { + observe() { Services.obs.removeObserver( this, "browser:purge-session-history-for-domain" diff --git a/browser/components/sessionstore/test/browser_464620_a.js b/browser/components/sessionstore/test/browser_464620_a.js index 9052d7bec0..6a3b56f767 100644 --- a/browser/components/sessionstore/test/browser_464620_a.js +++ b/browser/components/sessionstore/test/browser_464620_a.js @@ -27,7 +27,7 @@ function test() { let tab2 = gBrowser.duplicateTab(tab); tab2.linkedBrowser.addEventListener( "464620_a", - function listener(eventTab2) { + function listener() { tab2.linkedBrowser.removeEventListener("464620_a", listener, true); is(aEvent.data, "done", "XSS injection was attempted"); diff --git a/browser/components/sessionstore/test/browser_464620_b.js b/browser/components/sessionstore/test/browser_464620_b.js index 005bb4cc27..3e2b46d685 100644 --- a/browser/components/sessionstore/test/browser_464620_b.js +++ b/browser/components/sessionstore/test/browser_464620_b.js @@ -27,7 +27,7 @@ function test() { let tab2 = gBrowser.duplicateTab(tab); tab2.linkedBrowser.addEventListener( "464620_b", - function listener(eventTab2) { + function listener() { tab2.linkedBrowser.removeEventListener("464620_b", listener, true); is(aEvent.data, "done", "XSS injection was attempted"); diff --git a/browser/components/sessionstore/test/browser_526613.js b/browser/components/sessionstore/test/browser_526613.js index ba3f03ef32..784febd3d5 100644 --- a/browser/components/sessionstore/test/browser_526613.js +++ b/browser/components/sessionstore/test/browser_526613.js @@ -45,7 +45,7 @@ function test() { }; let pass = 1; - function observer(aSubject, aTopic, aData) { + function observer(aSubject, aTopic) { is( aTopic, "sessionstore-browser-state-restored", diff --git a/browser/components/sessionstore/test/browser_580512.js b/browser/components/sessionstore/test/browser_580512.js index 1dfd696277..e27dc61ba3 100644 --- a/browser/components/sessionstore/test/browser_580512.js +++ b/browser/components/sessionstore/test/browser_580512.js @@ -32,10 +32,10 @@ function closeFirstWin(win) { win.gBrowser.pinTab(win.gBrowser.tabs[1]); let winClosed = BrowserTestUtils.windowClosed(win); - // We need to call BrowserTryToCloseWindow in order to trigger + // We need to call BrowserCommands.tryToCloseWindow in order to trigger // the machinery that chooses whether or not to save the session // for the last window. - win.BrowserTryToCloseWindow(); + win.BrowserCommands.tryToCloseWindow(); ok(win.closed, "window closed"); winClosed.then(() => { @@ -88,7 +88,7 @@ function openWinWithCb(cb, argURIs, expectedURIs) { var expectedLoads = expectedURIs.length; win.gBrowser.addTabsProgressListener({ - onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, _aStatus) { if ( aRequest && aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && diff --git a/browser/components/sessionstore/test/browser_586068-apptabs.js b/browser/components/sessionstore/test/browser_586068-apptabs.js index b2f92f760c..24878ba267 100644 --- a/browser/components/sessionstore/test/browser_586068-apptabs.js +++ b/browser/components/sessionstore/test/browser_586068-apptabs.js @@ -69,12 +69,7 @@ add_task(async function test() { let loadCount = 0; let promiseRestoringTabs = new Promise(resolve => { - gProgressListener.setCallback(function ( - aBrowser, - aNeedRestore, - aRestoring, - aRestored - ) { + gProgressListener.setCallback(function (aBrowser) { loadCount++; // We'll make sure that the loads we get come from pinned tabs or the diff --git a/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js b/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js index b729555ff1..6ef18e2b3a 100644 --- a/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js +++ b/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js @@ -147,12 +147,7 @@ add_task(async function test() { let loadCount = 0; let promiseRestoringTabs = new Promise(resolve => { - gProgressListener.setCallback(function ( - aBrowser, - aNeedRestore, - aRestoring, - aRestored - ) { + gProgressListener.setCallback(function (aBrowser, aNeedRestore) { loadCount++; if ( @@ -188,7 +183,7 @@ add_task(async function test() { }); // We also want to catch the extra windows (there should be 2), so we need to observe domwindowopened - Services.ww.registerNotification(function observer(aSubject, aTopic, aData) { + Services.ww.registerNotification(function observer(aSubject, aTopic) { if (aTopic == "domwindowopened") { let win = aSubject; win.addEventListener( diff --git a/browser/components/sessionstore/test/browser_586068-multi_window.js b/browser/components/sessionstore/test/browser_586068-multi_window.js index bf5d839812..352c5bcefb 100644 --- a/browser/components/sessionstore/test/browser_586068-multi_window.js +++ b/browser/components/sessionstore/test/browser_586068-multi_window.js @@ -72,12 +72,7 @@ add_task(async function test() { let loadCount = 0; let promiseRestoringTabs = new Promise(resolve => { - gProgressListener.setCallback(function ( - aBrowser, - aNeedRestore, - aRestoring, - aRestored - ) { + gProgressListener.setCallback(function (aBrowser, aNeedRestore) { if (++loadCount == numTabs) { // We don't actually care about load order in this test, just that they all // do load. @@ -91,7 +86,7 @@ add_task(async function test() { }); // We also want to catch the 2nd window, so we need to observe domwindowopened - Services.ww.registerNotification(function observer(aSubject, aTopic, aData) { + Services.ww.registerNotification(function observer(aSubject, aTopic) { if (aTopic == "domwindowopened") { let win = aSubject; win.addEventListener( diff --git a/browser/components/sessionstore/test/browser_586068-window_state.js b/browser/components/sessionstore/test/browser_586068-window_state.js index 69c3742a66..25066a2db4 100644 --- a/browser/components/sessionstore/test/browser_586068-window_state.js +++ b/browser/components/sessionstore/test/browser_586068-window_state.js @@ -82,12 +82,7 @@ add_task(async function test() { let loadCount = 0; let promiseRestoringTabs = new Promise(resolve => { - gProgressListener.setCallback(function ( - aBrowser, - aNeedRestore, - aRestoring, - aRestored - ) { + gProgressListener.setCallback(function (aBrowser, aNeedRestore) { // When loadCount == 2, we'll also restore state2 into the window if (++loadCount == 2) { ss.setWindowState(window, JSON.stringify(state2), false); diff --git a/browser/components/sessionstore/test/browser_586068-window_state_override.js b/browser/components/sessionstore/test/browser_586068-window_state_override.js index 8a6eac6de2..eb3d2c709b 100644 --- a/browser/components/sessionstore/test/browser_586068-window_state_override.js +++ b/browser/components/sessionstore/test/browser_586068-window_state_override.js @@ -82,12 +82,7 @@ add_task(async function test() { let loadCount = 0; let promiseRestoringTabs = new Promise(resolve => { - gProgressListener.setCallback(function ( - aBrowser, - aNeedRestore, - aRestoring, - aRestored - ) { + gProgressListener.setCallback(function (aBrowser, aNeedRestore) { // When loadCount == 2, we'll also restore state2 into the window if (++loadCount == 2) { executeSoon(() => diff --git a/browser/components/sessionstore/test/browser_589246.js b/browser/components/sessionstore/test/browser_589246.js index 2fd92b2b82..34d9dc97a8 100644 --- a/browser/components/sessionstore/test/browser_589246.js +++ b/browser/components/sessionstore/test/browser_589246.js @@ -164,7 +164,7 @@ function setupForTest(aConditions) { ss.setBrowserState(JSON.stringify(testState)); } -function onStateRestored(aSubject, aTopic, aData) { +function onStateRestored() { info("test #" + testNum + ": onStateRestored"); Services.obs.removeObserver( onStateRestored, @@ -183,7 +183,7 @@ function onStateRestored(aSubject, aTopic, aData) { ); newWin.addEventListener( "load", - function (aEvent) { + function () { promiseBrowserLoaded(newWin.gBrowser.selectedBrowser).then(() => { // pin this tab if (shouldPinTab) { @@ -216,12 +216,12 @@ function onStateRestored(aSubject, aTopic, aData) { newWin.gBrowser.removeTab(newTab); newWin.gBrowser.removeTab(newTab2); } - newWin.BrowserTryToCloseWindow(); + newWin.BrowserCommands.tryToCloseWindow(); }, { capture: true, once: true } ); } else { - newWin.BrowserTryToCloseWindow(); + newWin.BrowserCommands.tryToCloseWindow(); } }); }, @@ -230,7 +230,7 @@ function onStateRestored(aSubject, aTopic, aData) { } // This will be called before the window is actually closed -function onLastWindowClosed(aSubject, aTopic, aData) { +function onLastWindowClosed() { info("test #" + testNum + ": onLastWindowClosed"); Services.obs.removeObserver( onLastWindowClosed, @@ -261,7 +261,7 @@ function onWindowUnloaded() { ); newWin.addEventListener( "load", - function (aEvent) { + function () { newWin.gBrowser.selectedBrowser.addEventListener( "load", function () { diff --git a/browser/components/sessionstore/test/browser_590268.js b/browser/components/sessionstore/test/browser_590268.js index cde1a1cafa..eb1940e35d 100644 --- a/browser/components/sessionstore/test/browser_590268.js +++ b/browser/components/sessionstore/test/browser_590268.js @@ -52,7 +52,7 @@ function test() { } } - function onSSTabRestored(aEvent) { + function onSSTabRestored() { if (++restoredTabsCount < NUM_TABS) { return; } diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js index b3ad6d240a..b2f7692b7c 100644 --- a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js @@ -29,11 +29,11 @@ function test_duplicateTab() { // We'll look to make sure this value is on the duplicated tab ss.setCustomTabValue(tab, "foo", "bar"); - function onSSWindowStateBusy(aEvent) { + function onSSWindowStateBusy() { busyEventCount++; } - function onSSWindowStateReady(aEvent) { + function onSSWindowStateReady() { newTab = gBrowser.tabs[2]; readyEventCount++; is(ss.getCustomTabValue(newTab, "foo"), "bar"); diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js index 4dfcbc844d..fbca3301e6 100644 --- a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js @@ -84,7 +84,7 @@ function test() { // waitForBrowserState does it's own observing for windows, but doesn't attach // the listeners we want here, so do it ourselves. let newWindow; - function windowObserver(aSubject, aTopic, aData) { + function windowObserver(aSubject, aTopic) { if (aTopic == "domwindowopened") { Services.ww.unregisterNotification(windowObserver); diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js index a76a8b3dd5..4b0c256388 100644 --- a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js @@ -29,17 +29,17 @@ function test_setTabState() { let busyEventCount = 0; let readyEventCount = 0; - function onSSWindowStateBusy(aEvent) { + function onSSWindowStateBusy() { busyEventCount++; } - function onSSWindowStateReady(aEvent) { + function onSSWindowStateReady() { readyEventCount++; is(ss.getCustomTabValue(tab, "foo"), "bar"); ss.setCustomTabValue(tab, "baz", "qux"); } - function onSSTabRestoring(aEvent) { + function onSSTabRestoring() { is(busyEventCount, 1); is(readyEventCount, 1); is(ss.getCustomTabValue(tab, "baz"), "qux"); diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js index c9d4bd00f5..daa40bd75a 100644 --- a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js @@ -29,17 +29,17 @@ function test() { readyEventCount = 0, tabRestoredCount = 0; - function onSSWindowStateBusy(aEvent) { + function onSSWindowStateBusy() { busyEventCount++; } - function onSSWindowStateReady(aEvent) { + function onSSWindowStateReady() { readyEventCount++; is(ss.getCustomTabValue(gBrowser.tabs[0], "foo"), "bar"); is(ss.getCustomTabValue(gBrowser.tabs[1], "baz"), "qux"); } - function onSSTabRestored(aEvent) { + function onSSTabRestored() { if (++tabRestoredCount < 2) { return; } diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js index 345bba516c..b5d5af2835 100644 --- a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js @@ -24,11 +24,11 @@ add_task(async function test_undoCloseTab() { ss.setCustomTabValue(tab, "foo", "bar"); - function onSSWindowStateBusy(aEvent) { + function onSSWindowStateBusy() { busyEventCount++; } - function onSSWindowStateReady(aEvent) { + function onSSWindowStateReady() { Assert.equal(gBrowser.tabs.length, 2, "Should only have 2 tabs"); lastTab = gBrowser.tabs[1]; readyEventCount++; diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js index 0a5b07da29..7483583e5a 100644 --- a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js @@ -71,7 +71,7 @@ function test() { let newWindow, reopenedWindow; - function firstWindowObserver(aSubject, aTopic, aData) { + function firstWindowObserver(aSubject, aTopic) { if (aTopic == "domwindowopened") { newWindow = aSubject; Services.ww.unregisterNotification(firstWindowObserver); @@ -107,15 +107,15 @@ function test() { readyEventCount = 0, tabRestoredCount = 0; // These will listen to the reopened closed window... - function onSSWindowStateBusy(aEvent) { + function onSSWindowStateBusy() { busyEventCount++; } - function onSSWindowStateReady(aEvent) { + function onSSWindowStateReady() { readyEventCount++; } - function onSSTabRestored(aEvent) { + function onSSTabRestored() { if (++tabRestoredCount < 4) { return; } diff --git a/browser/components/sessionstore/test/browser_618151.js b/browser/components/sessionstore/test/browser_618151.js index c38a349818..f3c44d1e88 100644 --- a/browser/components/sessionstore/test/browser_618151.js +++ b/browser/components/sessionstore/test/browser_618151.js @@ -46,7 +46,7 @@ function runNextTest() { } function test_setup() { - function onSSTabRestored(aEvent) { + function onSSTabRestored() { gBrowser.tabContainer.removeEventListener("SSTabRestored", onSSTabRestored); runNextTest(); } diff --git a/browser/components/sessionstore/test/browser_636279.js b/browser/components/sessionstore/test/browser_636279.js index 3b71fcbb4c..4842f145b2 100644 --- a/browser/components/sessionstore/test/browser_636279.js +++ b/browser/components/sessionstore/test/browser_636279.js @@ -129,7 +129,7 @@ var TabsProgressListener = { delete this.callback; }, - observe(browser, topic, data) { + observe(browser) { TabsProgressListener.onRestored(browser); }, diff --git a/browser/components/sessionstore/test/browser_645428.js b/browser/components/sessionstore/test/browser_645428.js index bbb3b1b299..3916c44a7e 100644 --- a/browser/components/sessionstore/test/browser_645428.js +++ b/browser/components/sessionstore/test/browser_645428.js @@ -6,7 +6,7 @@ const NOTIFICATION = "sessionstore-browser-state-restored"; function test() { waitForExplicitFinish(); - function observe(subject, topic, data) { + function observe(subject, topic) { if (NOTIFICATION == topic) { finish(); ok(true, "TOPIC received"); diff --git a/browser/components/sessionstore/test/browser_687710_2.js b/browser/components/sessionstore/test/browser_687710_2.js index 81d3c55379..190b5a718a 100644 --- a/browser/components/sessionstore/test/browser_687710_2.js +++ b/browser/components/sessionstore/test/browser_687710_2.js @@ -38,61 +38,31 @@ var state = { add_task(async function test() { let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); await promiseTabState(tab, state); - if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(tab.linkedBrowser, [], function () { - function compareEntries(i, j, history) { - let e1 = history.getEntryAtIndex(i); - let e2 = history.getEntryAtIndex(j); - ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); - is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); + function compareEntries(i, j, history) { + let e1 = history.getEntryAtIndex(i); + let e2 = history.getEntryAtIndex(j); - for (let c = 0; c < e1.childCount; c++) { - let c1 = e1.GetChildAt(c); - let c2 = e2.GetChildAt(c); + ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); + is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); - ok( - c1.sharesDocumentWith(c2), - `Cousins should share documents. (${i}, ${j}, ${c})` - ); - } - } + for (let c = 0; c < e1.childCount; c++) { + let c1 = e1.GetChildAt(c); + let c2 = e2.GetChildAt(c); - let history = docShell.browsingContext.childSessionHistory.legacySHistory; - - is(history.count, 2, "history.count"); - for (let i = 0; i < history.count; i++) { - for (let j = 0; j < history.count; j++) { - compareEntries(i, j, history); - } - } - }); - } else { - function compareEntries(i, j, history) { - let e1 = history.getEntryAtIndex(i); - let e2 = history.getEntryAtIndex(j); - - ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); - is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); - - for (let c = 0; c < e1.childCount; c++) { - let c1 = e1.GetChildAt(c); - let c2 = e2.GetChildAt(c); - - ok( - c1.sharesDocumentWith(c2), - `Cousins should share documents. (${i}, ${j}, ${c})` - ); - } + ok( + c1.sharesDocumentWith(c2), + `Cousins should share documents. (${i}, ${j}, ${c})` + ); } + } - let history = tab.linkedBrowser.browsingContext.sessionHistory; + let history = tab.linkedBrowser.browsingContext.sessionHistory; - is(history.count, 2, "history.count"); - for (let i = 0; i < history.count; i++) { - for (let j = 0; j < history.count; j++) { - compareEntries(i, j, history); - } + is(history.count, 2, "history.count"); + for (let i = 0; i < history.count; i++) { + for (let j = 0; j < history.count; j++) { + compareEntries(i, j, history); } } diff --git a/browser/components/sessionstore/test/browser_705597.js b/browser/components/sessionstore/test/browser_705597.js index d497e46a97..10f4f08863 100644 --- a/browser/components/sessionstore/test/browser_705597.js +++ b/browser/components/sessionstore/test/browser_705597.js @@ -26,14 +26,8 @@ function test() { let browser = tab.linkedBrowser; promiseTabState(tab, tabState).then(() => { - let entry; - if (!Services.appinfo.sessionHistoryInParent) { - let sessionHistory = browser.sessionHistory; - entry = sessionHistory.legacySHistory.getEntryAtIndex(0); - } else { - let sessionHistory = browser.browsingContext.sessionHistory; - entry = sessionHistory.getEntryAtIndex(0); - } + let sessionHistory = browser.browsingContext.sessionHistory; + let entry = sessionHistory.getEntryAtIndex(0); whenChildCount(entry, 1, function () { whenChildCount(entry, 2, function () { diff --git a/browser/components/sessionstore/test/browser_707862.js b/browser/components/sessionstore/test/browser_707862.js index 765c63257f..4559362e21 100644 --- a/browser/components/sessionstore/test/browser_707862.js +++ b/browser/components/sessionstore/test/browser_707862.js @@ -26,26 +26,14 @@ function test() { let browser = tab.linkedBrowser; promiseTabState(tab, tabState).then(() => { - let entry; - if (!Services.appinfo.sessionHistoryInParent) { - let sessionHistory = browser.sessionHistory; - entry = sessionHistory.legacySHistory.getEntryAtIndex(0); - } else { - let sessionHistory = browser.browsingContext.sessionHistory; - entry = sessionHistory.getEntryAtIndex(0); - } + let sessionHistory = browser.browsingContext.sessionHistory; + let entry = sessionHistory.getEntryAtIndex(0); whenChildCount(entry, 1, function () { whenChildCount(entry, 2, function () { promiseBrowserLoaded(browser).then(() => { - let newEntry; - if (!Services.appinfo.sessionHistoryInParent) { - let newSessionHistory = browser.sessionHistory; - newEntry = newSessionHistory.legacySHistory.getEntryAtIndex(0); - } else { - let newSessionHistory = browser.browsingContext.sessionHistory; - newEntry = newSessionHistory.getEntryAtIndex(0); - } + let newSessionHistory = browser.browsingContext.sessionHistory; + let newEntry = newSessionHistory.getEntryAtIndex(0); whenChildCount(newEntry, 0, function () { // Make sure that we reset the state. diff --git a/browser/components/sessionstore/test/browser_739531.js b/browser/components/sessionstore/test/browser_739531.js index 507d10a5f1..e02a94d9a7 100644 --- a/browser/components/sessionstore/test/browser_739531.js +++ b/browser/components/sessionstore/test/browser_739531.js @@ -19,7 +19,7 @@ function test() { removeFunc = BrowserTestUtils.addContentEventListener( tab.linkedBrowser, "load", - function onLoad(aEvent) { + function onLoad() { // make sure both the page and the frame are loaded if (++loadCount < 2) { return; diff --git a/browser/components/sessionstore/test/browser_async_flushes.js b/browser/components/sessionstore/test/browser_async_flushes.js index e35593dc30..d0bf039ff2 100644 --- a/browser/components/sessionstore/test/browser_async_flushes.js +++ b/browser/components/sessionstore/test/browser_async_flushes.js @@ -44,58 +44,6 @@ add_task(async function test_flush() { gBrowser.removeTab(tab); }); -add_task(async function test_crash() { - if (Services.appinfo.sessionHistoryInParent) { - // This test relies on frame script message ordering. Since the frame script - // is unused with SHIP, there's no guarantee that we'll crash the frame - // before we've started the flush. - ok(true, "Test relies on frame script message ordering."); - return; - } - - // Create new tab. - let tab = BrowserTestUtils.addTab(gBrowser, URL); - gBrowser.selectedTab = tab; - let browser = tab.linkedBrowser; - await promiseBrowserLoaded(browser); - - // Flush to empty any queued update messages. - await TabStateFlusher.flush(browser); - - // There should be one history entry. - let { entries } = JSON.parse(ss.getTabState(tab)); - is(entries.length, 1, "there is a single history entry"); - - // Click the link to navigate. - await SpecialPowers.spawn(browser, [], async function () { - return new Promise(resolve => { - docShell.chromeEventHandler.addEventListener( - "hashchange", - () => resolve(), - { once: true, capture: true } - ); - - // Click the link. - content.document.querySelector("a").click(); - }); - }); - - // Crash the browser and flush. Both messages are async and will be sent to - // the content process. The "crash" message makes it first so that we don't - // get a chance to process the flush. The TabStateFlusher however should be - // notified so that the flush still completes. - let promise1 = BrowserTestUtils.crashFrame(browser); - let promise2 = TabStateFlusher.flush(browser); - await Promise.all([promise1, promise2]); - - // The pending update should be lost. - ({ entries } = JSON.parse(ss.getTabState(tab))); - is(entries.length, 1, "still only one history entry"); - - // Cleanup. - gBrowser.removeTab(tab); -}); - add_task(async function test_remove() { // Create new tab. let tab = BrowserTestUtils.addTab(gBrowser, URL); diff --git a/browser/components/sessionstore/test/browser_async_remove_tab.js b/browser/components/sessionstore/test/browser_async_remove_tab.js index 7f74c57b40..1e3a75adfa 100644 --- a/browser/components/sessionstore/test/browser_async_remove_tab.js +++ b/browser/components/sessionstore/test/browser_async_remove_tab.js @@ -92,15 +92,7 @@ add_task(async function save_worthy_tabs_remote_final() { ok(browser.isRemoteBrowser, "browser is still remote"); // Remove the tab before the update arrives. - let promise = promiseRemoveTabAndSessionState(tab); - - // With SHIP, we'll do the final tab state update sooner than we did before. - if (!Services.appinfo.sessionHistoryInParent) { - // No tab state worth saving (that we know about yet). - ok(!isValueInClosedData(r), "closed tab not saved"); - } - - await promise; + await promiseRemoveTabAndSessionState(tab); // Turns out there is a tab state worth saving. ok(isValueInClosedData(r), "closed tab saved"); @@ -117,15 +109,7 @@ add_task(async function save_worthy_tabs_nonremote_final() { ok(!browser.isRemoteBrowser, "browser is not remote anymore"); // Remove the tab before the update arrives. - let promise = promiseRemoveTabAndSessionState(tab); - - // With SHIP, we'll do the final tab state update sooner than we did before. - if (!Services.appinfo.sessionHistoryInParent) { - // No tab state worth saving (that we know about yet). - ok(!isValueInClosedData(r), "closed tab not saved"); - } - - await promise; + await promiseRemoveTabAndSessionState(tab); // Turns out there is a tab state worth saving. ok(isValueInClosedData(r), "closed tab saved"); @@ -151,15 +135,7 @@ add_task(async function dont_save_empty_tabs_final() { await entryReplaced; // Remove the tab before the update arrives. - let promise = promiseRemoveTabAndSessionState(tab); - - // With SHIP, we'll do the final tab state update sooner than we did before. - if (!Services.appinfo.sessionHistoryInParent) { - // Tab state deemed worth saving (yet). - ok(isValueInClosedData(r), "closed tab saved"); - } - - await promise; + await promiseRemoveTabAndSessionState(tab); // Turns out we don't want to save the tab state. ok(!isValueInClosedData(r), "closed tab not saved"); diff --git a/browser/components/sessionstore/test/browser_async_window_flushing.js b/browser/components/sessionstore/test/browser_async_window_flushing.js index d346f9eb1f..42e24bdd83 100644 --- a/browser/components/sessionstore/test/browser_async_window_flushing.js +++ b/browser/components/sessionstore/test/browser_async_window_flushing.js @@ -116,17 +116,10 @@ add_task(async function test_remove_uninteresting_window() { await SpecialPowers.spawn(browser, [], async function () { // Epic hackery to make this browser seem suddenly boring. docShell.setCurrentURIForSessionStore(Services.io.newURI("about:blank")); - - if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { - let { sessionHistory } = docShell.QueryInterface(Ci.nsIWebNavigation); - sessionHistory.legacySHistory.purgeHistory(sessionHistory.count); - } }); - if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { - let { sessionHistory } = browser.browsingContext; - sessionHistory.purgeHistory(sessionHistory.count); - } + let { sessionHistory } = browser.browsingContext; + sessionHistory.purgeHistory(sessionHistory.count); // Once this windowClosed Promise resolves, we should have finished // the flush and revisited our decision to put this window into diff --git a/browser/components/sessionstore/test/browser_attributes.js b/browser/components/sessionstore/test/browser_attributes.js index a0ee6d5b0c..491ec5db22 100644 --- a/browser/components/sessionstore/test/browser_attributes.js +++ b/browser/components/sessionstore/test/browser_attributes.js @@ -38,45 +38,68 @@ add_task(async function test() { ok(tab.hasAttribute("muted"), "tab.muted exists"); // Make sure we do not persist 'image' and 'muted' attributes. - ss.persistTabAttribute("image"); - ss.persistTabAttribute("muted"); let { attributes } = JSON.parse(ss.getTabState(tab)); ok(!("image" in attributes), "'image' attribute not saved"); ok(!("muted" in attributes), "'muted' attribute not saved"); - ok(!("custom" in attributes), "'custom' attribute not saved"); - - // Test persisting a custom attribute. - tab.setAttribute("custom", "foobar"); - ss.persistTabAttribute("custom"); - - ({ attributes } = JSON.parse(ss.getTabState(tab))); - is(attributes.custom, "foobar", "'custom' attribute is correct"); - - // Make sure we're backwards compatible and restore old 'image' attributes. + ok(!("customizemode" in attributes), "'customizemode' attribute not saved"); + + // Test persisting a customizemode attribute. + { + let customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizationReady; + } + + let customizeIcon = gBrowser.getIcon(gBrowser.selectedTab); + ({ attributes } = JSON.parse(ss.getTabState(gBrowser.selectedTab))); + ok(!("image" in attributes), "'image' attribute not saved"); + is(attributes.customizemode, "true", "'customizemode' attribute is correct"); + + { + let afterCustomization = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + gCustomizeMode.exit(); + await afterCustomization; + } + + // Test restoring a customizemode tab. let state = { - entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], - attributes: { custom: "foobaz" }, - image: gBrowser.getIcon(tab), + entries: [], + attributes: { customizemode: "true", nonpersisted: "true" }, }; + // Customize mode doesn't like being restored on top of a non-blank tab. + // For the moment, it appears it isn't possible to restore customizemode onto + // an existing non-blank tab outside of tests, however this may be a latent + // bug if we ever try to do that in the future. + let principal = Services.scriptSecurityManager.createNullPrincipal({}); + tab.linkedBrowser.createAboutBlankDocumentViewer(principal, principal); + // Prepare a pending tab waiting to be restored. let promise = promiseTabRestoring(tab); ss.setTabState(tab, JSON.stringify(state)); await promise; ok(tab.hasAttribute("pending"), "tab is pending"); - is(gBrowser.getIcon(tab), state.image, "tab has correct icon"); + ok(tab.hasAttribute("customizemode"), "tab is in customizemode"); + ok(!tab.hasAttribute("nonpersisted"), "tab has no nonpersisted attribute"); + is(gBrowser.getIcon(tab), customizeIcon, "tab has correct icon"); ok(!state.attributes.image, "'image' attribute not saved"); // Let the pending tab load. gBrowser.selectedTab = tab; - await promiseTabRestored(tab); // Ensure no 'image' or 'pending' attributes are stored. ({ attributes } = JSON.parse(ss.getTabState(tab))); ok(!("image" in attributes), "'image' attribute not saved"); ok(!("pending" in attributes), "'pending' attribute not saved"); - is(attributes.custom, "foobaz", "'custom' attribute is correct"); + ok(!("nonpersisted" in attributes), "'nonpersisted' attribute not saved"); + is(attributes.customizemode, "true", "'customizemode' attribute is correct"); // Clean up. gBrowser.removeTab(tab); diff --git a/browser/components/sessionstore/test/browser_bfcache_telemetry.js b/browser/components/sessionstore/test/browser_bfcache_telemetry.js index 5faa2822ea..c1e9877505 100644 --- a/browser/components/sessionstore/test/browser_bfcache_telemetry.js +++ b/browser/components/sessionstore/test/browser_bfcache_telemetry.js @@ -39,7 +39,6 @@ async function test_bfcache_telemetry(probeInParent) { add_task(async () => { await test_bfcache_telemetry( - Services.appinfo.sessionHistoryInParent && - Services.prefs.getBoolPref("fission.bfcacheInParent") + Services.prefs.getBoolPref("fission.bfcacheInParent") ); }); diff --git a/browser/components/sessionstore/test/browser_closed_tabs_closed_windows.js b/browser/components/sessionstore/test/browser_closed_tabs_closed_windows.js index 081167acfa..c80e63df04 100644 --- a/browser/components/sessionstore/test/browser_closed_tabs_closed_windows.js +++ b/browser/components/sessionstore/test/browser_closed_tabs_closed_windows.js @@ -81,10 +81,6 @@ async function prepareClosedData() { const testWindow7 = await BrowserTestUtils.openNewBrowserWindow(); await openAndCloseTab(testWindow7, TEST_URLS[4]); - let closedTabsHistogram = TelemetryTestUtils.getAndClearHistogram( - "FX_SESSION_RESTORE_CLOSED_TABS_NOT_SAVED" - ); - await BrowserTestUtils.closeWindow(testWindow1); closedIds.testWindow1 = SessionStore.getClosedWindowData()[0].closedId; await BrowserTestUtils.closeWindow(testWindow2); @@ -100,13 +96,7 @@ async function prepareClosedData() { ); await BrowserTestUtils.closeWindow(testWindow6); - TelemetryTestUtils.assertHistogram(closedTabsHistogram, 0, 1); - closedTabsHistogram.clear(); - await BrowserTestUtils.closeWindow(testWindow7); - TelemetryTestUtils.assertHistogram(closedTabsHistogram, 1, 1); - closedTabsHistogram.clear(); - return closedIds; } diff --git a/browser/components/sessionstore/test/browser_cookies.js b/browser/components/sessionstore/test/browser_cookies.js index f514efc777..96244dda1a 100644 --- a/browser/components/sessionstore/test/browser_cookies.js +++ b/browser/components/sessionstore/test/browser_cookies.js @@ -14,7 +14,7 @@ function promiseSetCookie(cookie) { function waitForCookieChanged() { return new Promise(resolve => { - Services.obs.addObserver(function observer(subj, topic, data) { + Services.obs.addObserver(function observer(subj, topic) { Services.obs.removeObserver(observer, topic); resolve(); }, "session-cookie-changed"); diff --git a/browser/components/sessionstore/test/browser_crashedTabs.js b/browser/components/sessionstore/test/browser_crashedTabs.js index 32c064dd81..797cf5ecf8 100644 --- a/browser/components/sessionstore/test/browser_crashedTabs.js +++ b/browser/components/sessionstore/test/browser_crashedTabs.js @@ -82,7 +82,7 @@ function promiseTabCrashedReady(browser) { return new Promise(resolve => { browser.addEventListener( "AboutTabCrashedReady", - function ready(e) { + function ready() { browser.removeEventListener("AboutTabCrashedReady", ready, false, true); resolve(); }, diff --git a/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js b/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js index 1b152139d7..6fc212eb2b 100644 --- a/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js +++ b/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js @@ -4,38 +4,18 @@ add_task(async function duplicateTab() { let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); await BrowserTestUtils.browserLoaded(tab.linkedBrowser); - if (!Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(tab.linkedBrowser, [], function () { - let docshell = content.window.docShell.QueryInterface( - Ci.nsIWebNavigation - ); - let shEntry = docshell.sessionHistory.legacySHistory.getEntryAtIndex(0); - is(shEntry.docshellID.toString(), docshell.historyID.toString()); - }); - } else { - let historyID = tab.linkedBrowser.browsingContext.historyID; - let shEntry = - tab.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); - is(shEntry.docshellID.toString(), historyID.toString()); - } + let historyID = tab.linkedBrowser.browsingContext.historyID; + let shEntry = + tab.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), historyID.toString()); let tab2 = gBrowser.duplicateTab(tab); await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); - if (!Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(tab2.linkedBrowser, [], function () { - let docshell = content.window.docShell.QueryInterface( - Ci.nsIWebNavigation - ); - let shEntry = docshell.sessionHistory.legacySHistory.getEntryAtIndex(0); - is(shEntry.docshellID.toString(), docshell.historyID.toString()); - }); - } else { - let historyID = tab2.linkedBrowser.browsingContext.historyID; - let shEntry = - tab2.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); - is(shEntry.docshellID.toString(), historyID.toString()); - } + historyID = tab2.linkedBrowser.browsingContext.historyID; + shEntry = + tab2.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), historyID.toString()); BrowserTestUtils.removeTab(tab); BrowserTestUtils.removeTab(tab2); @@ -47,24 +27,10 @@ add_task(async function contentToChromeNavigate() { let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); await BrowserTestUtils.browserLoaded(tab.linkedBrowser); - if (!Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(tab.linkedBrowser, [], function () { - let docshell = content.window.docShell.QueryInterface( - Ci.nsIWebNavigation - ); - let sh = docshell.sessionHistory; - is(sh.count, 1); - is( - sh.legacySHistory.getEntryAtIndex(0).docshellID.toString(), - docshell.historyID.toString() - ); - }); - } else { - let historyID = tab.linkedBrowser.browsingContext.historyID; - let sh = tab.linkedBrowser.browsingContext.sessionHistory; - is(sh.count, 1); - is(sh.getEntryAtIndex(0).docshellID.toString(), historyID.toString()); - } + let historyID = tab.linkedBrowser.browsingContext.historyID; + let sh = tab.linkedBrowser.browsingContext.sessionHistory; + is(sh.count, 1); + is(sh.getEntryAtIndex(0).docshellID.toString(), historyID.toString()); // Force the browser to navigate to the chrome process. BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, "about:config"); @@ -74,31 +40,17 @@ add_task(async function contentToChromeNavigate() { let docShell = tab.linkedBrowser.frameLoader.docShell; // 'cause we're in the chrome process, we can just directly poke at the shistory. - if (!Services.appinfo.sessionHistoryInParent) { - let sh = docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory; - - is(sh.count, 2); - is( - sh.legacySHistory.getEntryAtIndex(0).docshellID.toString(), - docShell.historyID.toString() - ); - is( - sh.legacySHistory.getEntryAtIndex(1).docshellID.toString(), - docShell.historyID.toString() - ); - } else { - let sh = docShell.browsingContext.sessionHistory; - - is(sh.count, 2); - is( - sh.getEntryAtIndex(0).docshellID.toString(), - docShell.historyID.toString() - ); - is( - sh.getEntryAtIndex(1).docshellID.toString(), - docShell.historyID.toString() - ); - } + sh = docShell.browsingContext.sessionHistory; + + is(sh.count, 2); + is( + sh.getEntryAtIndex(0).docshellID.toString(), + docShell.historyID.toString() + ); + is( + sh.getEntryAtIndex(1).docshellID.toString(), + docShell.historyID.toString() + ); BrowserTestUtils.removeTab(tab); }); diff --git a/browser/components/sessionstore/test/browser_frame_history.js b/browser/components/sessionstore/test/browser_frame_history.js index 1db32e74ab..eeb6de177c 100644 --- a/browser/components/sessionstore/test/browser_frame_history.js +++ b/browser/components/sessionstore/test/browser_frame_history.js @@ -206,7 +206,7 @@ function waitForLoadsInBrowser(aBrowser, aLoadCount) { let loadCount = 0; aBrowser.addEventListener( "load", - function listener(aEvent) { + function listener() { if (++loadCount < aLoadCount) { info( "Got " + loadCount + " loads, waiting until we have " + aLoadCount diff --git a/browser/components/sessionstore/test/browser_frametree.js b/browser/components/sessionstore/test/browser_frametree.js index ce1f5cdf0b..06e0379c59 100644 --- a/browser/components/sessionstore/test/browser_frametree.js +++ b/browser/components/sessionstore/test/browser_frametree.js @@ -98,7 +98,7 @@ add_task(async function test_frametree_dynamic() { is(await enumerateIndexes(browser), "0,1", "correct indexes 0 and 1"); // Remopve a non-dynamic iframe. - await SpecialPowers.spawn(browser, [URL], async ([url]) => { + await SpecialPowers.spawn(browser, [URL], async () => { // Remove the first iframe, which should be a non-dynamic iframe. content.document.body.removeChild( content.document.getElementsByTagName("iframe")[0] diff --git a/browser/components/sessionstore/test/browser_history_persist.js b/browser/components/sessionstore/test/browser_history_persist.js index f6749b02e3..1cf8bf1b8d 100644 --- a/browser/components/sessionstore/test/browser_history_persist.js +++ b/browser/components/sessionstore/test/browser_history_persist.js @@ -25,54 +25,27 @@ add_task(async function check_history_not_persisted() { browser = tab.linkedBrowser; await promiseTabState(tab, state); - if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(browser, [], function () { - let sessionHistory = - docShell.browsingContext.childSessionHistory.legacySHistory; - - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - }); - } else { - let sessionHistory = browser.browsingContext.sessionHistory; - - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - } + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); // Load a new URL into the tab, it should replace the about:blank history entry BrowserTestUtils.startLoadingURIString(browser, "about:robots"); await promiseBrowserLoaded(browser, false, "about:robots"); - if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(browser, [], function () { - let sessionHistory = - docShell.browsingContext.childSessionHistory.legacySHistory; - - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:robots", - "Should be the right URL" - ); - }); - } else { - let sessionHistory = browser.browsingContext.sessionHistory; - - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:robots", - "Should be the right URL" - ); - } + + sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); // Cleanup. BrowserTestUtils.removeTab(tab); @@ -99,64 +72,33 @@ add_task(async function check_history_default_persisted() { tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); browser = tab.linkedBrowser; await promiseTabState(tab, state); - if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(browser, [], function () { - let sessionHistory = - docShell.browsingContext.childSessionHistory.legacySHistory; - - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - }); - } else { - let sessionHistory = browser.browsingContext.sessionHistory; - - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - } + + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); // Load a new URL into the tab, it should replace the about:blank history entry BrowserTestUtils.startLoadingURIString(browser, "about:robots"); await promiseBrowserLoaded(browser, false, "about:robots"); - if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { - await SpecialPowers.spawn(browser, [], function () { - let sessionHistory = - docShell.browsingContext.childSessionHistory.legacySHistory; - - is(sessionHistory.count, 2, "Should be two history entries"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - is( - sessionHistory.getEntryAtIndex(1).URI.spec, - "about:robots", - "Should be the right URL" - ); - }); - } else { - let sessionHistory = browser.browsingContext.sessionHistory; - - is(sessionHistory.count, 2, "Should be two history entries"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - is( - sessionHistory.getEntryAtIndex(1).URI.spec, - "about:robots", - "Should be the right URL" - ); - } + + sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); // Cleanup. BrowserTestUtils.removeTab(tab); diff --git a/browser/components/sessionstore/test/browser_newtab_userTypedValue.js b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js index 755a1f2859..cd17c9a9f0 100644 --- a/browser/components/sessionstore/test/browser_newtab_userTypedValue.js +++ b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js @@ -16,7 +16,7 @@ add_task(async function () { ); // This opens about:newtab: - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); let tab = await tabOpenedAndSwitchedTo; is(win.gURLBar.value, "", "URL bar should be empty"); is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); @@ -55,7 +55,7 @@ add_task(async function () { for (let url of gInitialPages) { if (url == BROWSER_NEW_TAB_URL) { - continue; // We tested about:newtab using BrowserOpenTab() above. + continue; // We tested about:newtab using BrowserCommands.openTab() above. } info("Testing " + url + " - " + new Date()); await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); diff --git a/browser/components/sessionstore/test/browser_oldformat.toml b/browser/components/sessionstore/test/browser_oldformat.toml new file mode 100644 index 0000000000..7edc51dc67 --- /dev/null +++ b/browser/components/sessionstore/test/browser_oldformat.toml @@ -0,0 +1,301 @@ +[DEFAULT] +support-files = [ + "head.js", + "browser_formdata_sample.html", + "browser_formdata_xpath_sample.html", + "browser_frametree_sample.html", + "browser_frametree_sample_frameset.html", + "browser_frametree_sample_iframes.html", + "browser_frame_history_index.html", + "browser_frame_history_index2.html", + "browser_frame_history_index_blank.html", + "browser_frame_history_a.html", + "browser_frame_history_b.html", + "browser_frame_history_c.html", + "browser_frame_history_c1.html", + "browser_frame_history_c2.html", + "browser_formdata_format_sample.html", + "browser_sessionHistory_slow.sjs", + "browser_scrollPositions_sample.html", + "browser_scrollPositions_sample2.html", + "browser_scrollPositions_sample_frameset.html", + "browser_scrollPositions_readerModeArticle.html", + "browser_sessionStorage.html", + "browser_speculative_connect.html", + "browser_248970_b_sample.html", + "browser_339445_sample.html", + "browser_423132_sample.html", + "browser_447951_sample.html", + "browser_454908_sample.html", + "browser_456342_sample.xhtml", + "browser_463205_sample.html", + "browser_463206_sample.html", + "browser_466937_sample.html", + "browser_485482_sample.html", + "browser_637020_slow.sjs", + "browser_662743_sample.html", + "browser_739531_sample.html", + "browser_739531_frame.html", + "browser_911547_sample.html", + "browser_911547_sample.html^headers^", + "coopHeaderCommon.sjs", + "restore_redirect_http.html", + "restore_redirect_http.html^headers^", + "restore_redirect_js.html", + "restore_redirect_target.html", + "browser_1234021_page.html", + "browser_1284886_suspend_tab.html", + "browser_1284886_suspend_tab_2.html", + "empty.html", + "coop_coep.html", + "coop_coep.html^headers^", +] +# remove this after bug 1628486 is landed +prefs = [ + "network.cookie.cookieBehavior=5", + "gfx.font_rendering.fallback.async=false", + "browser.sessionstore.closedTabsFromAllWindows=true", + "browser.sessionstore.closedTabsFromClosedWindows=true", +] + +#NB: the following are disabled +# browser_464620_a.html +# browser_464620_b.html +# browser_464620_xd.html + +#disabled-for-intermittent-failures--bug-766044, browser_459906_empty.html +#disabled-for-intermittent-failures--bug-766044, browser_459906_sample.html +#disabled-for-intermittent-failures--bug-765389, browser_461743_sample.html + +["browser_1234021.js"] + +["browser_1284886_suspend_tab.js"] + +["browser_1446343-windowsize.js"] +skip-if = ["os == 'linux'"] # Bug 1600180 + +["browser_248970_b_perwindowpb.js"] +# Disabled because of leaks. +# Re-enabling and rewriting this test is tracked in bug 936919. +skip-if = ["true"] + +["browser_339445.js"] + +["browser_345898.js"] + +["browser_350525.js"] + +["browser_354894_perwindowpb.js"] + +["browser_367052.js"] + +["browser_393716.js"] +skip-if = ["debug"] # Bug 1507747 + +["browser_394759_basic.js"] +# Disabled for intermittent failures, bug 944372. +skip-if = ["true"] + +["browser_394759_behavior.js"] +https_first_disabled = true + +["browser_394759_perwindowpb.js"] + +["browser_394759_purge.js"] + +["browser_423132.js"] + +["browser_447951.js"] + +["browser_454908.js"] + +["browser_456342.js"] + +["browser_461634.js"] + +["browser_463205.js"] + +["browser_463206.js"] + +["browser_464199.js"] +# Disabled for frequent intermittent failures + +["browser_464620_a.js"] +skip-if = ["true"] + +["browser_464620_b.js"] +skip-if = ["true"] + +["browser_465215.js"] + +["browser_465223.js"] + +["browser_466937.js"] + +["browser_467409-backslashplosion.js"] + +["browser_477657.js"] +skip-if = ["os == 'linux' && os_version == '18.04'"] # bug 1610668 for ubuntu 18.04 + +["browser_480893.js"] + +["browser_485482.js"] + +["browser_485563.js"] + +["browser_490040.js"] + +["browser_491168.js"] + +["browser_491577.js"] +skip-if = [ + "verify && debug && os == 'mac'", + "verify && debug && os == 'win'", +] + +["browser_495495.js"] + +["browser_500328.js"] + +["browser_514751.js"] + +["browser_522375.js"] + +["browser_522545.js"] +skip-if = ["true"] # Bug 1380968 + +["browser_524745.js"] +skip-if = [ + "win10_2009 && !ccov", # Bug 1418627 + "os == 'linux'", # Bug 1803187 +] + +["browser_528776.js"] + +["browser_579868.js"] + +["browser_579879.js"] +skip-if = ["os == 'linux' && (debug || asan)"] # Bug 1234404 + +["browser_581937.js"] + +["browser_586068-apptabs.js"] + +["browser_586068-apptabs_ondemand.js"] +skip-if = ["verify && (os == 'mac' || os == 'win')"] + +["browser_586068-browser_state_interrupted.js"] + +["browser_586068-cascade.js"] + +["browser_586068-multi_window.js"] + +["browser_586068-reload.js"] +https_first_disabled = true + +["browser_586068-select.js"] + +["browser_586068-window_state.js"] + +["browser_586068-window_state_override.js"] + +["browser_586147.js"] + +["browser_588426.js"] + +["browser_590268.js"] + +["browser_590563.js"] + +["browser_595601-restore_hidden.js"] + +["browser_597071.js"] +skip-if = ["true"] # Needs to be rewritten as Marionette test, bug 995916 + +["browser_600545.js"] + +["browser_601955.js"] + +["browser_607016.js"] + +["browser_615394-SSWindowState_events_duplicateTab.js"] + +["browser_615394-SSWindowState_events_setBrowserState.js"] +skip-if = ["verify && debug && os == 'mac'"] + +["browser_615394-SSWindowState_events_setTabState.js"] + +["browser_615394-SSWindowState_events_setWindowState.js"] +https_first_disabled = true + +["browser_615394-SSWindowState_events_undoCloseTab.js"] + +["browser_615394-SSWindowState_events_undoCloseWindow.js"] +skip-if = [ + "os == 'win' && !debug", # Bug 1572554 + "os == 'linux'", # Bug 1572554 +] + +["browser_618151.js"] + +["browser_623779.js"] + +["browser_624727.js"] + +["browser_625016.js"] +skip-if = [ + "os == 'mac'", # Disabled on OS X: + "os == 'linux'", # linux, Bug 1348583 + "os == 'win' && debug", # Bug 1430977 +] + +["browser_628270.js"] + +["browser_635418.js"] + +["browser_636279.js"] + +["browser_637020.js"] + +["browser_645428.js"] + +["browser_659591.js"] + +["browser_662743.js"] + +["browser_662812.js"] +skip-if = ["verify"] + +["browser_665702-state_session.js"] + +["browser_682507.js"] + +["browser_687710.js"] + +["browser_687710_2.js"] +https_first_disabled = true + +["browser_694378.js"] + +["browser_701377.js"] +skip-if = [ + "verify && debug && os == 'win'", + "verify && debug && os == 'mac'", +] + +["browser_705597.js"] + +["browser_707862.js"] + +["browser_739531.js"] + +["browser_739805.js"] + +["browser_819510_perwindowpb.js"] +skip-if = ["true"] # Bug 1284312, Bug 1341980, bug 1381451 + +["browser_906076_lazy_tabs.js"] +https_first_disabled = true +skip-if = ["os == 'linux' && os_version == '18.04'"] # bug 1446464 + +["browser_911547.js"] diff --git a/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js index 442914d580..ad8144f864 100644 --- a/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js +++ b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js @@ -12,7 +12,7 @@ const TESTURL = "about:testpageforsessionrestore#foo"; let TestAboutPage = { QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]), - getURIFlags(aURI) { + getURIFlags() { // No CAN_ or MUST_LOAD_IN_CHILD means this loads in the parent: return ( Ci.nsIAboutModule.ALLOW_SCRIPT | @@ -73,7 +73,7 @@ add_task(async function () { r => (resolveLocationChangePromise = r) ); let wpl = { - onStateChange(listener, request, state, status) { + onStateChange(listener, request, state, _status) { let location = request.QueryInterface(Ci.nsIChannel).originalURI; // Ignore about:blank loads. let docStop = diff --git a/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js b/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js index cc340c4617..10551238f5 100644 --- a/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js +++ b/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js @@ -208,7 +208,7 @@ add_task(async function test_reopen_last_tab_if_no_closed_actions() { gBrowser, url: "about:blank", }, - async browser => { + async () => { const TEST_URL = "https://example.com/"; let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); let update = BrowserTestUtils.waitForSessionStoreUpdate(tab); diff --git a/browser/components/sessionstore/test/browser_send_async_message_oom.js b/browser/components/sessionstore/test/browser_send_async_message_oom.js deleted file mode 100644 index 7e807f2fbd..0000000000 --- a/browser/components/sessionstore/test/browser_send_async_message_oom.js +++ /dev/null @@ -1,75 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - http://creativecommons.org/publicdomain/zero/1.0/ */ -/* eslint-disable mozilla/no-arbitrary-setTimeout */ - -const HISTOGRAM_NAME = "FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM"; - -/** - * Test that an OOM in sendAsyncMessage in a framescript will be reported - * to Telemetry. - */ - -add_setup(async function () { - Services.telemetry.canRecordExtended = true; -}); - -function frameScript() { - // Make send[A]syncMessage("SessionStore:update", ...) simulate OOM. - // Other operations are unaffected. - let mm = docShell.messageManager; - - let wrap = function (original) { - return function (name, ...args) { - if (name != "SessionStore:update") { - return original(name, ...args); - } - throw new Components.Exception( - "Simulated OOM", - Cr.NS_ERROR_OUT_OF_MEMORY - ); - }; - }; - - mm.sendAsyncMessage = wrap(mm.sendAsyncMessage.bind(mm)); - mm.sendSyncMessage = wrap(mm.sendSyncMessage.bind(mm)); -} - -add_task(async function () { - // Capture original state. - let snapshot = Services.telemetry.getHistogramById(HISTOGRAM_NAME).snapshot(); - - // Open a browser, configure it to cause OOM. - let newTab = BrowserTestUtils.addTab(gBrowser, "about:robots"); - let browser = newTab.linkedBrowser; - await ContentTask.spawn(browser, null, frameScript); - - let promiseReported = new Promise(resolve => { - browser.messageManager.addMessageListener("SessionStore:error", resolve); - }); - - // Attempt to flush. This should fail. - let promiseFlushed = TabStateFlusher.flush(browser); - promiseFlushed.then(success => { - if (success) { - throw new Error("Flush should have failed"); - } - }); - - // The frame script should report an error. - await promiseReported; - - // Give us some time to handle that error. - await new Promise(resolve => setTimeout(resolve, 10)); - - // By now, Telemetry should have been updated. - let snapshot2 = Services.telemetry - .getHistogramById(HISTOGRAM_NAME) - .snapshot(); - gBrowser.removeTab(newTab); - - Assert.ok(snapshot2.sum > snapshot.sum); -}); - -add_task(async function cleanup() { - Services.telemetry.canRecordExtended = false; -}); diff --git a/browser/components/sessionstore/test/browser_sessionHistory.js b/browser/components/sessionstore/test/browser_sessionHistory.js index 69dcc4995b..34b1ef7d09 100644 --- a/browser/components/sessionstore/test/browser_sessionHistory.js +++ b/browser/components/sessionstore/test/browser_sessionHistory.js @@ -296,12 +296,9 @@ add_task(async function test_slow_subframe_load() { * Ensure that document wireframes can be persisted when they're enabled. */ add_task(async function test_wireframes() { - // Wireframes only works when Fission and SHIP are enabled. - if ( - !Services.appinfo.fissionAutostart || - !Services.appinfo.sessionHistoryInParent - ) { - ok(true, "Skipping test_wireframes when Fission or SHIP is not enabled."); + // Wireframes only works when Fission is enabled. + if (!Services.appinfo.fissionAutostart) { + ok(true, "Skipping test_wireframes when Fission is not enabled."); return; } diff --git a/browser/components/sessionstore/test/browser_sessionStoreContainer.js b/browser/components/sessionstore/test/browser_sessionStoreContainer.js index 86833dea82..e4f3ecea9f 100644 --- a/browser/components/sessionstore/test/browser_sessionStoreContainer.js +++ b/browser/components/sessionstore/test/browser_sessionStoreContainer.js @@ -14,7 +14,7 @@ add_task(async function () { await promiseBrowserLoaded(browser); let tab2 = gBrowser.duplicateTab(tab); - Assert.equal(tab2.getAttribute("usercontextid"), i); + Assert.equal(tab2.getAttribute("usercontextid") || "", i); let browser2 = tab2.linkedBrowser; await promiseTabRestored(tab2); diff --git a/browser/components/sessionstore/test/browser_should_restore_tab.js b/browser/components/sessionstore/test/browser_should_restore_tab.js index ab9513083a..958222141e 100644 --- a/browser/components/sessionstore/test/browser_should_restore_tab.js +++ b/browser/components/sessionstore/test/browser_should_restore_tab.js @@ -13,7 +13,7 @@ async function check_tab_close_notification(openedTab, expectNotification) { let tabClosed = BrowserTestUtils.waitForTabClosing(openedTab); let notified = false; - function topicObserver(_, topic) { + function topicObserver() { notified = true; } Services.obs.addObserver(topicObserver, NOTIFY_CLOSED_OBJECTS_CHANGED); @@ -73,7 +73,7 @@ add_task(async function test_about_new_tab() { () => {} ); // This opens about:newtab: - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); let tab = await tabOpenedAndSwitchedTo; await check_tab_close_notification(tab, false); }); diff --git a/browser/components/sessionstore/test/browser_windowStateContainer.js b/browser/components/sessionstore/test/browser_windowStateContainer.js index f0d6f42d39..e2d2d256eb 100644 --- a/browser/components/sessionstore/test/browser_windowStateContainer.js +++ b/browser/components/sessionstore/test/browser_windowStateContainer.js @@ -11,7 +11,7 @@ add_setup(async function () { function promiseTabsRestored(win, nExpected) { return new Promise(resolve => { let nReceived = 0; - function handler(event) { + function handler() { if (++nReceived === nExpected) { win.gBrowser.tabContainer.removeEventListener( "SSTabRestored", diff --git a/browser/components/sessionstore/test/head.js b/browser/components/sessionstore/test/head.js index d475fa86a1..85db6e9d5e 100644 --- a/browser/components/sessionstore/test/head.js +++ b/browser/components/sessionstore/test/head.js @@ -144,7 +144,7 @@ function waitForTopic(aTopic, aTimeout, aCallback) { aCallback(false); }, aTimeout); - function observer(subject, topic, data) { + function observer() { removeObserver(); timeout = clearTimeout(timeout); executeSoon(() => aCallback(true)); @@ -268,7 +268,7 @@ var gWebProgressListener = { } }, - onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, _aStatus) { if ( aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK && @@ -298,7 +298,7 @@ var gProgressListener = { } }, - observe(browser, topic, data) { + observe(browser) { gProgressListener.onRestored(browser); }, @@ -451,7 +451,7 @@ function modifySessionStorage(browser, storageData, storageOptions = {}) { return SpecialPowers.spawn( browsingContext, [[storageData, storageOptions]], - async function ([data, options]) { + async function ([data]) { let frame = content; let keys = new Set(Object.keys(data)); let isClearing = !keys.size; @@ -558,35 +558,9 @@ function setPropertyOfFormField(browserContext, selector, propName, newValue) { } function promiseOnHistoryReplaceEntry(browser) { - if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { - return new Promise(resolve => { - let sessionHistory = browser.browsingContext?.sessionHistory; - if (sessionHistory) { - var historyListener = { - OnHistoryNewEntry() {}, - OnHistoryGotoIndex() {}, - OnHistoryPurge() {}, - OnHistoryReload() { - return true; - }, - - OnHistoryReplaceEntry() { - resolve(); - }, - - QueryInterface: ChromeUtils.generateQI([ - "nsISHistoryListener", - "nsISupportsWeakReference", - ]), - }; - - sessionHistory.addSHistoryListener(historyListener); - } - }); - } - - return SpecialPowers.spawn(browser, [], () => { - return new Promise(resolve => { + return new Promise(resolve => { + let sessionHistory = browser.browsingContext?.sessionHistory; + if (sessionHistory) { var historyListener = { OnHistoryNewEntry() {}, OnHistoryGotoIndex() {}, @@ -605,13 +579,8 @@ function promiseOnHistoryReplaceEntry(browser) { ]), }; - var { sessionHistory } = this.docShell.QueryInterface( - Ci.nsIWebNavigation - ); - if (sessionHistory) { - sessionHistory.legacySHistory.addSHistoryListener(historyListener); - } - }); + sessionHistory.addSHistoryListener(historyListener); + } }); } diff --git a/browser/components/shell/HeadlessShell.sys.mjs b/browser/components/shell/HeadlessShell.sys.mjs index c87a7a6d56..7882031613 100644 --- a/browser/components/shell/HeadlessShell.sys.mjs +++ b/browser/components/shell/HeadlessShell.sys.mjs @@ -35,7 +35,7 @@ function loadContentWindow(browser, url) { } const principal = Services.scriptSecurityManager.getSystemPrincipal(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { let oa = E10SUtils.predictOriginAttributes({ browser, }); diff --git a/browser/components/shell/ShellService.sys.mjs b/browser/components/shell/ShellService.sys.mjs index c4af0be7de..ed0c86d1a3 100644 --- a/browser/components/shell/ShellService.sys.mjs +++ b/browser/components/shell/ShellService.sys.mjs @@ -9,6 +9,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", }); XPCOMUtils.defineLazyServiceGetter( @@ -18,6 +19,13 @@ XPCOMUtils.defineLazyServiceGetter( "nsIXREDirProvider" ); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "BackgroundTasks", + "@mozilla.org/backgroundtasks;1", + "nsIBackgroundTasks" +); + ChromeUtils.defineLazyGetter(lazy, "log", () => { let { ConsoleAPI } = ChromeUtils.importESModule( "resource://gre/modules/Console.sys.mjs" @@ -337,6 +345,16 @@ let ShellServiceInternal = { } this.shellService.setDefaultBrowser(forAllUsers); + + // Disable showing toast notification from Firefox Background Tasks. + if (!lazy.BackgroundTasks?.isBackgroundTaskMode) { + await lazy.ASRouter.waitForInitialized; + const win = Services.wm.getMostRecentBrowserWindow() ?? null; + lazy.ASRouter.sendTriggerMessage({ + browser: win, + id: "deeplinkedToWindowsSettingsUI", + }); + } }, async setAsDefault() { diff --git a/browser/components/shell/content/setDesktopBackground.js b/browser/components/shell/content/setDesktopBackground.js index 7448a3e076..70ab825354 100644 --- a/browser/components/shell/content/setDesktopBackground.js +++ b/browser/components/shell/content/setDesktopBackground.js @@ -234,7 +234,7 @@ if (AppConstants.platform != "macosx") { ); }; } else { - gSetBackground.observe = function (aSubject, aTopic, aData) { + gSetBackground.observe = function (aSubject, aTopic) { if (aTopic == "shell:desktop-background-changed") { document.getElementById("setDesktopBackground").hidden = true; document.getElementById("showDesktopPreferences").hidden = false; diff --git a/browser/components/shell/nsIWindowsShellService.idl b/browser/components/shell/nsIWindowsShellService.idl index 13c824f39c..d28b713a78 100644 --- a/browser/components/shell/nsIWindowsShellService.idl +++ b/browser/components/shell/nsIWindowsShellService.idl @@ -112,7 +112,7 @@ interface nsIWindowsShellService : nsISupports * successful or rejects with an nserror. */ [implicit_jscontext] - Promise pinCurrentAppToTaskbarAsync(in bool aPrivateBrowsing); + Promise pinCurrentAppToTaskbarAsync(in boolean aPrivateBrowsing); /* * Do a dry run of pinCurrentAppToTaskbar(). @@ -128,7 +128,7 @@ interface nsIWindowsShellService : nsISupports * @returns same as pinCurrentAppToTaskbarAsync() */ [implicit_jscontext] - Promise checkPinCurrentAppToTaskbarAsync(in bool aPrivateBrowsing); + Promise checkPinCurrentAppToTaskbarAsync(in boolean aPrivateBrowsing); /* * Search for the current executable among taskbar pins @@ -247,7 +247,7 @@ interface nsIWindowsShellService : nsISupports AString classifyShortcut(in AString aPath); [implicit_jscontext] - Promise hasMatchingShortcut(in AString aAUMID, in bool aPrivateBrowsing); + Promise hasMatchingShortcut(in AString aAUMID, in boolean aPrivateBrowsing); /* * Check if setDefaultBrowserUserChoice() is expected to succeed. @@ -257,7 +257,7 @@ interface nsIWindowsShellService : nsISupports * * @return true if the check succeeds, false otherwise. */ - bool canSetDefaultBrowserUserChoice(); + boolean canSetDefaultBrowserUserChoice(); /* * checkAllProgIDsExist() and checkBrowserUserChoiceHashes() are components @@ -265,8 +265,8 @@ interface nsIWindowsShellService : nsISupports * * @return true if the check succeeds, false otherwise. */ - bool checkAllProgIDsExist(); - bool checkBrowserUserChoiceHashes(); + boolean checkAllProgIDsExist(); + boolean checkBrowserUserChoiceHashes(); /* * Determines whether or not Firefox is the "Default Handler", i.e., diff --git a/browser/components/shell/test/browser_1119088.js b/browser/components/shell/test/browser_1119088.js index bc0995fe51..62fc953f44 100644 --- a/browser/components/shell/test/browser_1119088.js +++ b/browser/components/shell/test/browser_1119088.js @@ -101,7 +101,7 @@ add_task(async function () { gBrowser, url: "about:logo", }, - async browser => { + async () => { let dirSvc = Cc["@mozilla.org/file/directory_service;1"].getService( Ci.nsIDirectoryServiceProvider ); diff --git a/browser/components/shell/test/browser_420786.js b/browser/components/shell/test/browser_420786.js index 025cd87943..9cbdc8c4b7 100644 --- a/browser/components/shell/test/browser_420786.js +++ b/browser/components/shell/test/browser_420786.js @@ -14,7 +14,7 @@ add_task(async function () { gBrowser, url: "about:logo", }, - browser => { + () => { var brandName = Services.strings .createBundle("chrome://branding/locale/brand.properties") .GetStringFromName("brandShortName"); diff --git a/browser/components/shell/test/browser_setDesktopBackgroundPreview.js b/browser/components/shell/test/browser_setDesktopBackgroundPreview.js index b2dbe13db8..b8e2a38dd5 100644 --- a/browser/components/shell/test/browser_setDesktopBackgroundPreview.js +++ b/browser/components/shell/test/browser_setDesktopBackgroundPreview.js @@ -12,7 +12,7 @@ add_task(async function () { gBrowser, url: "about:logo", }, - async browser => { + async () => { const dialogLoad = BrowserTestUtils.domWindowOpened(null, async win => { await BrowserTestUtils.waitForEvent(win, "load"); Assert.equal( diff --git a/browser/components/shell/test/head.js b/browser/components/shell/test/head.js index db1f8811fd..692ba918d0 100644 --- a/browser/components/shell/test/head.js +++ b/browser/components/shell/test/head.js @@ -89,7 +89,7 @@ async function testWindowSizePositive(width, height) { } let data = await IOUtils.read(screenshotPath); - await new Promise((resolve, reject) => { + await new Promise(resolve => { let blob = new Blob([data], { type: "image/png" }); let reader = new FileReader(); reader.onloadend = function () { @@ -126,7 +126,7 @@ async function testGreen(url, path) { } let data = await IOUtils.read(path); - let image = await new Promise((resolve, reject) => { + let image = await new Promise(resolve => { let blob = new Blob([data], { type: "image/png" }); let reader = new FileReader(); reader.onloadend = function () { diff --git a/browser/components/shopping/tests/browser/browser_exposure_telemetry.js b/browser/components/shopping/tests/browser/browser_exposure_telemetry.js index 51334ce722..f76126aa9d 100644 --- a/browser/components/shopping/tests/browser/browser_exposure_telemetry.js +++ b/browser/components/shopping/tests/browser/browser_exposure_telemetry.js @@ -30,7 +30,7 @@ async function setup(pref) { Services.fog.testResetFOG(); } -async function teardown(pref) { +async function teardown() { await SpecialPowers.popPrefEnv(); await Services.fog.testFlushAllChildren(); Services.fog.testResetFOG(); diff --git a/browser/components/shopping/tests/browser/browser_shopping_settings.js b/browser/components/shopping/tests/browser/browser_shopping_settings.js index 2508be05c7..a32488239e 100644 --- a/browser/components/shopping/tests/browser/browser_shopping_settings.js +++ b/browser/components/shopping/tests/browser/browser_shopping_settings.js @@ -16,7 +16,7 @@ add_task(async function test_shopping_settings_fakespot_learn_more() { await SpecialPowers.spawn( browser, [MOCK_ANALYZED_PRODUCT_RESPONSE], - async mockData => { + async () => { let shoppingContainer = content.document.querySelector( "shopping-container" @@ -55,7 +55,7 @@ add_task(async function test_shopping_settings_ads_learn_more() { await SpecialPowers.spawn( browser, [MOCK_ANALYZED_PRODUCT_RESPONSE], - async mockData => { + async () => { let shoppingContainer = content.document.querySelector( "shopping-container" @@ -404,7 +404,7 @@ add_task( await SpecialPowers.spawn( sidebar.querySelector("browser"), [MOCK_ANALYZED_PRODUCT_RESPONSE], - async mockData => { + async () => { let shoppingContainer = content.document.querySelector( "shopping-container" @@ -490,7 +490,7 @@ add_task( await SpecialPowers.spawn( sidebar.querySelector("browser"), [MOCK_ANALYZED_PRODUCT_RESPONSE], - async mockData => { + async () => { let shoppingContainer = content.document.querySelector( "shopping-container" diff --git a/browser/components/shopping/tests/browser/browser_shopping_urlbar.js b/browser/components/shopping/tests/browser/browser_shopping_urlbar.js index 9eb396e846..d89db3ebcb 100644 --- a/browser/components/shopping/tests/browser/browser_shopping_urlbar.js +++ b/browser/components/shopping/tests/browser/browser_shopping_urlbar.js @@ -7,7 +7,7 @@ const CONTENT_PAGE = "https://example.com"; const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; add_task(async function test_button_hidden() { - await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function () { let shoppingButton = document.getElementById("shopping-sidebar-button"); ok( BrowserTestUtils.isHidden(shoppingButton), @@ -17,7 +17,7 @@ add_task(async function test_button_hidden() { }); add_task(async function test_button_shown() { - await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function () { let shoppingButton = document.getElementById("shopping-sidebar-button"); ok( BrowserTestUtils.isVisible(shoppingButton), @@ -52,7 +52,7 @@ add_task(async function test_button_changes_with_location() { add_task(async function test_button_active() { Services.prefs.setBoolPref("browser.shopping.experience2023.active", true); - await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function () { let shoppingButton = document.getElementById("shopping-sidebar-button"); Assert.equal( shoppingButton.getAttribute("shoppingsidebaropen"), @@ -65,7 +65,7 @@ add_task(async function test_button_active() { add_task(async function test_button_inactive() { Services.prefs.setBoolPref("browser.shopping.experience2023.active", false); - await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function () { let shoppingButton = document.getElementById("shopping-sidebar-button"); Assert.equal( shoppingButton.getAttribute("shoppingsidebaropen"), @@ -245,7 +245,7 @@ add_task(async function test_button_right_click_doesnt_affect_sidebars() { add_task(async function test_button_deals_with_tabswitches() { Services.prefs.setBoolPref("browser.shopping.experience2023.active", true); - await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function () { let shoppingButton = document.getElementById("shopping-sidebar-button"); ok( diff --git a/browser/components/shopping/tests/browser/browser_ui_telemetry.js b/browser/components/shopping/tests/browser/browser_ui_telemetry.js index b97aca1963..69bdf50bd1 100644 --- a/browser/components/shopping/tests/browser/browser_ui_telemetry.js +++ b/browser/components/shopping/tests/browser/browser_ui_telemetry.js @@ -259,7 +259,7 @@ add_task(async function test_close_telemetry_recorded() { set: [["browser.shopping.experience2023.active", true]], }); - await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function () { let shoppingButton = document.getElementById("shopping-sidebar-button"); shoppingButton.click(); }); diff --git a/browser/components/sidebar/browser-sidebar.js b/browser/components/sidebar/browser-sidebar.js new file mode 100644 index 0000000000..55664f8cfc --- /dev/null +++ b/browser/components/sidebar/browser-sidebar.js @@ -0,0 +1,744 @@ +/* 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/. */ + +/** + * SidebarUI controls showing and hiding the browser sidebar. + */ +var SidebarUI = { + get sidebars() { + if (this._sidebars) { + return this._sidebars; + } + + function makeSidebar({ elementId, ...rest }) { + return { + get sourceL10nEl() { + return document.getElementById(elementId); + }, + get title() { + return document.getElementById(elementId).getAttribute("label"); + }, + ...rest, + }; + } + + return (this._sidebars = new Map([ + [ + "viewBookmarksSidebar", + makeSidebar({ + elementId: "sidebar-switcher-bookmarks", + url: "chrome://browser/content/places/bookmarksSidebar.xhtml", + menuId: "menu_bookmarksSidebar", + }), + ], + [ + "viewHistorySidebar", + makeSidebar({ + elementId: "sidebar-switcher-history", + url: this.sidebarRevampEnabled + ? "chrome://browser/content/sidebar/sidebar-history.html" + : "chrome://browser/content/places/historySidebar.xhtml", + menuId: "menu_historySidebar", + triggerButtonId: "appMenuViewHistorySidebar", + }), + ], + [ + "viewTabsSidebar", + makeSidebar({ + elementId: "sidebar-switcher-tabs", + url: this.sidebarRevampEnabled + ? "chrome://browser/content/sidebar/sidebar-syncedtabs.html" + : "chrome://browser/content/syncedtabs/sidebar.xhtml", + menuId: "menu_tabsSidebar", + }), + ], + [ + "viewMegalistSidebar", + makeSidebar({ + elementId: "sidebar-switcher-megalist", + url: "chrome://global/content/megalist/megalist.html", + menuId: "menu_megalistSidebar", + }), + ], + ])); + }, + + // Avoid getting the browser element from init() to avoid triggering the + // <browser> constructor during startup if the sidebar is hidden. + get browser() { + if (this._browser) { + return this._browser; + } + return (this._browser = document.getElementById("sidebar")); + }, + POSITION_START_PREF: "sidebar.position_start", + DEFAULT_SIDEBAR_ID: "viewBookmarksSidebar", + + // lastOpenedId is set in show() but unlike currentID it's not cleared out on hide + // and isn't persisted across windows + lastOpenedId: null, + + _box: null, + // The constructor of this label accesses the browser element due to the + // control="sidebar" attribute, so avoid getting this label during startup. + get _title() { + if (this.__title) { + return this.__title; + } + return (this.__title = document.getElementById("sidebar-title")); + }, + _splitter: null, + _reversePositionButton: null, + _switcherPanel: null, + _switcherTarget: null, + _switcherArrow: null, + _inited: false, + + /** + * @type {MutationObserver | null} + */ + _observer: null, + + _initDeferred: Promise.withResolvers(), + + get promiseInitialized() { + return this._initDeferred.promise; + }, + + get initialized() { + return this._inited; + }, + + async init() { + this._box = document.getElementById("sidebar-box"); + this._splitter = document.getElementById("sidebar-splitter"); + this._reversePositionButton = document.getElementById( + "sidebar-reverse-position" + ); + this._switcherPanel = document.getElementById("sidebarMenu-popup"); + this._switcherTarget = document.getElementById("sidebar-switcher-target"); + this._switcherArrow = document.getElementById("sidebar-switcher-arrow"); + + if (this.sidebarRevampEnabled) { + await import("chrome://browser/content/sidebar/sidebar-launcher.mjs"); + document.getElementById("sidebar-launcher").hidden = false; + document.getElementById("sidebar-header").hidden = true; + } else { + this._switcherTarget.addEventListener("command", () => { + this.toggleSwitcherPanel(); + }); + this._switcherTarget.addEventListener("keydown", event => { + this.handleKeydown(event); + }); + } + + this._inited = true; + + Services.obs.addObserver(this, "intl:app-locales-changed"); + + this._initDeferred.resolve(); + }, + + toggleMegalistItem() { + const sideMenuPopupItem = document.getElementById( + "sidebar-switcher-megalist" + ); + sideMenuPopupItem.style.display = Services.prefs.getBoolPref( + "browser.megalist.enabled", + false + ) + ? "" + : "none"; + }, + + setMegalistMenubarVisibility(aEvent) { + const popup = aEvent.target; + if (popup != aEvent.currentTarget) { + return; + } + + // Show the megalist item if enabled + const megalistItem = popup.querySelector("#menu_megalistSidebar"); + megalistItem.hidden = !Services.prefs.getBoolPref( + "browser.megalist.enabled", + false + ); + }, + + uninit() { + // If this is the last browser window, persist various values that should be + // remembered for after a restart / reopening a browser window. + let enumerator = Services.wm.getEnumerator("navigator:browser"); + if (!enumerator.hasMoreElements()) { + let xulStore = Services.xulStore; + xulStore.persist(this._box, "sidebarcommand"); + + if (this._box.hasAttribute("positionend")) { + xulStore.persist(this._box, "positionend"); + } else { + xulStore.removeValue( + document.documentURI, + "sidebar-box", + "positionend" + ); + } + if (this._box.hasAttribute("checked")) { + xulStore.persist(this._box, "checked"); + } else { + xulStore.removeValue(document.documentURI, "sidebar-box", "checked"); + } + + xulStore.persist(this._box, "style"); + xulStore.persist(this._title, "value"); + } + + Services.obs.removeObserver(this, "intl:app-locales-changed"); + + if (this._observer) { + this._observer.disconnect(); + this._observer = null; + } + }, + + /** + * The handler for Services.obs.addObserver. + */ + observe(_subject, topic, _data) { + switch (topic) { + case "intl:app-locales-changed": { + if (this.isOpen) { + // The <tree> component used in history and bookmarks, but it does not + // support live switching the app locale. Reload the entire sidebar to + // invalidate any old text. + this.hide(); + this.showInitially(this.lastOpenedId); + break; + } + } + } + }, + + /** + * Ensure the title stays in sync with the source element, which updates for + * l10n changes. + * + * @param {HTMLElement} [element] + */ + observeTitleChanges(element) { + if (!element) { + return; + } + let observer = this._observer; + if (!observer) { + observer = new MutationObserver(() => { + this.title = this.sidebars.get(this.lastOpenedId).title; + }); + // Re-use the observer. + this._observer = observer; + } + observer.disconnect(); + observer.observe(element, { + attributes: true, + attributeFilter: ["label"], + }); + }, + + /** + * Opens the switcher panel if it's closed, or closes it if it's open. + */ + toggleSwitcherPanel() { + if ( + this._switcherPanel.state == "open" || + this._switcherPanel.state == "showing" + ) { + this.hideSwitcherPanel(); + } else if (this._switcherPanel.state == "closed") { + this.showSwitcherPanel(); + } + }, + + /** + * Handles keydown on the the switcherTarget button + * + * @param {Event} event + */ + handleKeydown(event) { + switch (event.key) { + case "Enter": + case " ": { + this.toggleSwitcherPanel(); + event.stopPropagation(); + event.preventDefault(); + break; + } + case "Escape": { + this.hideSwitcherPanel(); + event.stopPropagation(); + event.preventDefault(); + break; + } + } + }, + + hideSwitcherPanel() { + this._switcherPanel.hidePopup(); + }, + + showSwitcherPanel() { + this.toggleMegalistItem(); + this._switcherPanel.addEventListener( + "popuphiding", + () => { + this._switcherTarget.classList.remove("active"); + this._switcherTarget.setAttribute("aria-expanded", false); + }, + { once: true } + ); + + // Combine start/end position with ltr/rtl to set the label in the popup appropriately. + let label = + this._positionStart == RTL_UI + ? gNavigatorBundle.getString("sidebar.moveToLeft") + : gNavigatorBundle.getString("sidebar.moveToRight"); + this._reversePositionButton.setAttribute("label", label); + + // Open the sidebar switcher popup, anchored off the switcher toggle + this._switcherPanel.hidden = false; + this._switcherPanel.openPopup(this._switcherTarget); + + this._switcherTarget.classList.add("active"); + this._switcherTarget.setAttribute("aria-expanded", true); + }, + + updateShortcut({ keyId }) { + let menuitem = this._switcherPanel?.querySelector(`[key="${keyId}"]`); + if (!menuitem) { + // If the menu item doesn't exist yet then the accel text will be set correctly + // upon creation so there's nothing to do now. + return; + } + menuitem.removeAttribute("acceltext"); + }, + + /** + * Change the pref that will trigger a call to setPosition + */ + reversePosition() { + Services.prefs.setBoolPref(this.POSITION_START_PREF, !this._positionStart); + }, + + /** + * Read the positioning pref and position the sidebar and the splitter + * appropriately within the browser container. + */ + setPosition() { + // First reset all ordinals to match DOM ordering. + let browser = document.getElementById("browser"); + [...browser.children].forEach((node, i) => { + node.style.order = i + 1; + }); + let sidebarLauncher = document.querySelector("sidebar-launcher"); + + if (!this._positionStart) { + // DOM ordering is: sidebar-launcher | sidebar-box | splitter | appcontent | + // Want to display as: | appcontent | splitter | sidebar-box | sidebar-launcher + // So we just swap box and appcontent ordering and move sidebar-launcher to the end + let appcontent = document.getElementById("appcontent"); + let boxOrdinal = this._box.style.order; + this._box.style.order = appcontent.style.order; + + appcontent.style.order = boxOrdinal; + // the launcher should be on the right of the sidebar-box + sidebarLauncher.style.order = parseInt(this._box.style.order) + 1; + // Indicate we've switched ordering to the box + this._box.setAttribute("positionend", true); + sidebarLauncher.setAttribute("positionend", true); + } else { + this._box.removeAttribute("positionend"); + sidebarLauncher.removeAttribute("positionend"); + } + + this.hideSwitcherPanel(); + + let content = SidebarUI.browser.contentWindow; + if (content && content.updatePosition) { + content.updatePosition(); + } + }, + + /** + * Try and adopt the status of the sidebar from another window. + * + * @param {Window} sourceWindow - Window to use as a source for sidebar status. + * @returns {boolean} true if we adopted the state, or false if the caller should + * initialize the state itself. + */ + adoptFromWindow(sourceWindow) { + // If the opener had a sidebar, open the same sidebar in our window. + // The opener can be the hidden window too, if we're coming from the state + // where no windows are open, and the hidden window has no sidebar box. + let sourceUI = sourceWindow.SidebarUI; + if (!sourceUI || !sourceUI._box) { + // no source UI or no _box means we also can't adopt the state. + return false; + } + + // Set sidebar command even if hidden, so that we keep the same sidebar + // even if it's currently closed. + let commandID = sourceUI._box.getAttribute("sidebarcommand"); + if (commandID) { + this._box.setAttribute("sidebarcommand", commandID); + } + + if (sourceUI._box.hidden) { + // just hidden means we have adopted the hidden state. + return true; + } + + // dynamically generated sidebars will fail this check, but we still + // consider it adopted. + if (!this.sidebars.has(commandID)) { + return true; + } + + this._box.style.width = sourceUI._box.getBoundingClientRect().width + "px"; + this.showInitially(commandID); + + return true; + }, + + windowPrivacyMatches(w1, w2) { + return ( + PrivateBrowsingUtils.isWindowPrivate(w1) === + PrivateBrowsingUtils.isWindowPrivate(w2) + ); + }, + + /** + * If loading a sidebar was delayed on startup, start the load now. + */ + startDelayedLoad() { + let sourceWindow = window.opener; + // No source window means this is the initial window. If we're being + // opened from another window, check that it is one we might open a sidebar + // for. + if (sourceWindow) { + if ( + sourceWindow.closed || + sourceWindow.location.protocol != "chrome:" || + !this.windowPrivacyMatches(sourceWindow, window) + ) { + return; + } + // Try to adopt the sidebar state from the source window + if (this.adoptFromWindow(sourceWindow)) { + return; + } + } + + // If we're not adopting settings from a parent window, set them now. + let wasOpen = this._box.getAttribute("checked"); + if (!wasOpen) { + return; + } + + let commandID = this._box.getAttribute("sidebarcommand"); + if (commandID && this.sidebars.has(commandID)) { + this.showInitially(commandID); + } else { + this._box.removeAttribute("checked"); + // Remove the |sidebarcommand| attribute, because the element it + // refers to no longer exists, so we should assume this sidebar + // panel has been uninstalled. (249883) + // We use setAttribute rather than removeAttribute so it persists + // correctly. + this._box.setAttribute("sidebarcommand", ""); + // On a startup in which the startup cache was invalidated (e.g. app update) + // extensions will not be started prior to delayedLoad, thus the + // sidebarcommand element will not exist yet. Store the commandID so + // extensions may reopen if necessary. A startup cache invalidation + // can be forced (for testing) by deleting compatibility.ini from the + // profile. + this.lastOpenedId = commandID; + } + }, + + /** + * Fire a "SidebarShown" event on the sidebar to give any interested parties + * a chance to update the button or whatever. + */ + _fireShowEvent() { + let event = new CustomEvent("SidebarShown", { bubbles: true }); + this._switcherTarget.dispatchEvent(event); + }, + + /** + * Fire a "SidebarFocused" event on the sidebar's |window| to give the sidebar + * a chance to adjust focus as needed. An additional event is needed, because + * we don't want to focus the sidebar when it's opened on startup or in a new + * window, only when the user opens the sidebar. + */ + _fireFocusedEvent() { + let event = new CustomEvent("SidebarFocused", { bubbles: true }); + this.browser.contentWindow.dispatchEvent(event); + }, + + /** + * True if the sidebar is currently open. + */ + get isOpen() { + return !this._box.hidden; + }, + + /** + * The ID of the current sidebar. + */ + get currentID() { + return this.isOpen ? this._box.getAttribute("sidebarcommand") : ""; + }, + + get title() { + return this._title.value; + }, + + set title(value) { + this._title.value = value; + }, + + /** + * Toggle the visibility of the sidebar. If the sidebar is hidden or is open + * with a different commandID, then the sidebar will be opened using the + * specified commandID. Otherwise the sidebar will be hidden. + * + * @param {string} commandID ID of the sidebar. + * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the + * visibility toggling of the sidebar. + * @returns {Promise} + */ + toggle(commandID = this.lastOpenedId, triggerNode) { + if ( + CustomizationHandler.isCustomizing() || + CustomizationHandler.isExitingCustomizeMode + ) { + return Promise.resolve(); + } + // First priority for a default value is this.lastOpenedId which is set during show() + // and not reset in hide(), unlike currentID. If show() hasn't been called and we don't + // have a persisted command either, or the command doesn't exist anymore, then + // fallback to a default sidebar. + if (!commandID) { + commandID = this._box.getAttribute("sidebarcommand"); + } + if (!commandID || !this.sidebars.has(commandID)) { + commandID = this.DEFAULT_SIDEBAR_ID; + } + + if (this.isOpen && commandID == this.currentID) { + this.hide(triggerNode); + return Promise.resolve(); + } + return this.show(commandID, triggerNode); + }, + + _loadSidebarExtension(commandID) { + let sidebar = this.sidebars.get(commandID); + let { extensionId } = sidebar; + if (extensionId) { + SidebarUI.browser.contentWindow.loadPanel( + extensionId, + sidebar.panel, + sidebar.browserStyle + ); + } + }, + + /** + * Show the sidebar. + * + * This wraps the internal method, including a ping to telemetry. + * + * @param {string} commandID ID of the sidebar to use. + * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the + * showing of the sidebar. + * @returns {Promise<boolean>} + */ + async show(commandID, triggerNode) { + let panelType = commandID.substring(4, commandID.length - 7); + Services.telemetry.keyedScalarAdd("sidebar.opened", panelType, 1); + + // Extensions without private window access wont be in the + // sidebars map. + if (!this.sidebars.has(commandID)) { + return false; + } + return this._show(commandID).then(() => { + this._loadSidebarExtension(commandID); + + if (triggerNode) { + updateToggleControlLabel(triggerNode); + } + + this._fireFocusedEvent(); + return true; + }); + }, + + /** + * Show the sidebar, without firing the focused event or logging telemetry. + * This is intended to be used when the sidebar is opened automatically + * when a window opens (not triggered by user interaction). + * + * @param {string} commandID ID of the sidebar. + * @returns {Promise<boolean>} + */ + async showInitially(commandID) { + let panelType = commandID.substring(4, commandID.length - 7); + Services.telemetry.keyedScalarAdd("sidebar.opened", panelType, 1); + + // Extensions without private window access wont be in the + // sidebars map. + if (!this.sidebars.has(commandID)) { + return false; + } + return this._show(commandID).then(() => { + this._loadSidebarExtension(commandID); + return true; + }); + }, + + /** + * Implementation for show. Also used internally for sidebars that are shown + * when a window is opened and we don't want to ping telemetry. + * + * @param {string} commandID ID of the sidebar. + * @returns {Promise<void>} + */ + _show(commandID) { + return new Promise(resolve => { + if (this.sidebarRevampEnabled) { + this._box.dispatchEvent( + new CustomEvent("sidebar-show", { detail: { viewId: commandID } }) + ); + } else { + this.hideSwitcherPanel(); + } + + this.selectMenuItem(commandID); + this._box.hidden = this._splitter.hidden = false; + // sets the sidebar to the left or right, based on a pref + this.setPosition(); + + this._box.setAttribute("checked", "true"); + this._box.setAttribute("sidebarcommand", commandID); + + let { url, title, sourceL10nEl } = this.sidebars.get(commandID); + + // use to live update <tree> elements if the locale changes + this.lastOpenedId = commandID; + this.title = title; + // Keep the title element in the switcher in sync with any l10n changes. + this.observeTitleChanges(sourceL10nEl); + + this.browser.setAttribute("src", url); // kick off async load + + if (this.browser.contentDocument.location.href != url) { + this.browser.addEventListener( + "load", + () => { + // We're handling the 'load' event before it bubbles up to the usual + // (non-capturing) event handlers. Let it bubble up before resolving. + setTimeout(() => { + resolve(); + + // Now that the currentId is updated, fire a show event. + this._fireShowEvent(); + }, 0); + }, + { capture: true, once: true } + ); + } else { + resolve(); + + // Now that the currentId is updated, fire a show event. + this._fireShowEvent(); + } + }); + }, + + /** + * Hide the sidebar. + * + * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the + * hiding of the sidebar. + */ + hide(triggerNode) { + if (!this.isOpen) { + return; + } + + this.hideSwitcherPanel(); + if (this.sidebarRevampEnabled) { + this._box.dispatchEvent(new CustomEvent("sidebar-hide")); + } + this.selectMenuItem(""); + + // Replace the document currently displayed in the sidebar with about:blank + // so that we can free memory by unloading the page. We need to explicitly + // create a new content viewer because the old one doesn't get destroyed + // until about:blank has loaded (which does not happen as long as the + // element is hidden). + this.browser.setAttribute("src", "about:blank"); + this.browser.docShell.createAboutBlankDocumentViewer(null, null); + + this._box.removeAttribute("checked"); + this._box.hidden = this._splitter.hidden = true; + + let selBrowser = gBrowser.selectedBrowser; + selBrowser.focus(); + if (triggerNode) { + updateToggleControlLabel(triggerNode); + } + }, + + /** + * Sets the checked state only on the menu items of the specified sidebar, or + * none if the argument is an empty string. + */ + selectMenuItem(commandID) { + for (let [id, { menuId, triggerButtonId }] of this.sidebars) { + let menu = document.getElementById(menuId); + let triggerbutton = + triggerButtonId && document.getElementById(triggerButtonId); + if (id == commandID) { + menu.setAttribute("checked", "true"); + if (triggerbutton) { + triggerbutton.setAttribute("checked", "true"); + updateToggleControlLabel(triggerbutton); + } + } else { + menu.removeAttribute("checked"); + if (triggerbutton) { + triggerbutton.removeAttribute("checked"); + updateToggleControlLabel(triggerbutton); + } + } + } + }, +}; + +// Add getters related to the position here, since we will want them +// available for both startDelayedLoad and init. +XPCOMUtils.defineLazyPreferenceGetter( + SidebarUI, + "_positionStart", + SidebarUI.POSITION_START_PREF, + true, + SidebarUI.setPosition.bind(SidebarUI) +); +XPCOMUtils.defineLazyPreferenceGetter( + SidebarUI, + "sidebarRevampEnabled", + "sidebar.revamp", + false +); diff --git a/browser/components/sidebar/jar.mn b/browser/components/sidebar/jar.mn index c3d7f0cbcf..8a7071ca72 100644 --- a/browser/components/sidebar/jar.mn +++ b/browser/components/sidebar/jar.mn @@ -3,3 +3,12 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. browser.jar: + content/browser/sidebar/browser-sidebar.js + content/browser/sidebar/sidebar-launcher.css + content/browser/sidebar/sidebar-launcher.mjs + content/browser/sidebar/sidebar-history.html + content/browser/sidebar/sidebar-history.mjs + content/browser/sidebar/sidebar-page.mjs + content/browser/sidebar/sidebar-syncedtabs.html + content/browser/sidebar/sidebar-syncedtabs.mjs + content/browser/sidebar/sidebar.css diff --git a/browser/components/sidebar/sidebar-history.html b/browser/components/sidebar/sidebar-history.html new file mode 100644 index 0000000000..f1df5c507a --- /dev/null +++ b/browser/components/sidebar/sidebar-history.html @@ -0,0 +1,34 @@ +<!-- 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 resource: chrome:; object-src 'none'; img-src data: chrome:;" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="firefoxview-page-title"></title> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/branding/accounts.ftl" /> + <link rel="localization" href="browser/firefoxView.ftl" /> + <link rel="localization" href="preview/sidebar.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <link + rel="stylesheet" + href="chrome://browser/content/sidebar/sidebar.css" + /> + <script + type="module" + src="chrome://browser/content/sidebar/sidebar-history.mjs" + ></script> + <script src="chrome://browser/content/contentTheme.js"></script> + </head> + + <body> + <sidebar-history /> + </body> +</html> diff --git a/browser/components/sidebar/sidebar-history.mjs b/browser/components/sidebar/sidebar-history.mjs new file mode 100644 index 0000000000..6c662b2c6f --- /dev/null +++ b/browser/components/sidebar/sidebar-history.mjs @@ -0,0 +1,201 @@ +/* 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/. */ + +import { html, when } from "chrome://global/content/vendor/lit.all.mjs"; + +import { SidebarPage } from "./sidebar-page.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/fxview-search-textbox.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/fxview-tab-list.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-card.mjs"; +import { HistoryController } from "chrome://browser/content/firefoxview/HistoryController.mjs"; +import { navigateToLink } from "chrome://browser/content/firefoxview/helpers.mjs"; + +const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; + +export class SidebarHistory extends SidebarPage { + constructor() { + super(); + this._started = false; + // Setting maxTabsLength to -1 for no max + this.maxTabsLength = -1; + } + + controller = new HistoryController(this, { + component: "sidebar", + }); + + connectedCallback() { + super.connectedCallback(); + this.controller.updateAllHistoryItems(); + } + + onPrimaryAction(e) { + navigateToLink(e); + } + + deleteFromHistory() { + this.controller.deleteFromHistory(); + } + + /** + * The template to use for cards-container. + */ + get cardsTemplate() { + if (this.controller.searchResults) { + return this.#searchResultsTemplate(); + } else if (this.controller.allHistoryItems.size) { + return this.#historyCardsTemplate(); + } + return this.#emptyMessageTemplate(); + } + + #historyCardsTemplate() { + let cardsTemplate = []; + this.controller.historyMapByDate.forEach(historyItem => { + if (historyItem.items.length) { + let dateArg = JSON.stringify({ date: historyItem.items[0].time }); + cardsTemplate.push(html`<moz-card + type="accordion" + data-l10n-attrs="heading" + data-l10n-id=${historyItem.l10nId} + data-l10n-args=${dateArg} + > + <div> + <fxview-tab-list + compactRows + class="with-context-menu" + maxTabsLength=${this.maxTabsLength} + .tabItems=${this.getTabItems(historyItem.items)} + @fxview-tab-list-primary-action=${this.onPrimaryAction} + .updatesPaused=${false} + > + </fxview-tab-list> + </div> + </moz-card>`); + } + }); + return cardsTemplate; + } + + #emptyMessageTemplate() { + let descriptionHeader; + let descriptionLabels; + let descriptionLink; + if (Services.prefs.getBoolPref(NEVER_REMEMBER_HISTORY_PREF, false)) { + // History pref set to never remember history + descriptionHeader = "firefoxview-dont-remember-history-empty-header"; + descriptionLabels = [ + "firefoxview-dont-remember-history-empty-description", + "firefoxview-dont-remember-history-empty-description-two", + ]; + descriptionLink = { + url: "about:preferences#privacy", + name: "history-settings-url-two", + }; + } else { + descriptionHeader = "firefoxview-history-empty-header"; + descriptionLabels = [ + "firefoxview-history-empty-description", + "firefoxview-history-empty-description-two", + ]; + descriptionLink = { + url: "about:preferences#privacy", + name: "history-settings-url", + }; + } + return html` + <fxview-empty-state + headerLabel=${descriptionHeader} + .descriptionLabels=${descriptionLabels} + .descriptionLink=${descriptionLink} + class="empty-state history" + ?isSelectedTab=${this.selectedTab} + mainImageUrl="chrome://browser/content/firefoxview/history-empty.svg" + > + </fxview-empty-state> + `; + } + + #searchResultsTemplate() { + return html` <moz-card + data-l10n-attrs="heading" + data-l10n-id="sidebar-search-results-header" + data-l10n-args=${JSON.stringify({ + query: this.controller.searchQuery, + })} + > + <div> + ${when( + this.controller.searchResults.length, + () => + html`<h3 + slot="secondary-header" + data-l10n-id="firefoxview-search-results-count" + data-l10n-args="${JSON.stringify({ + count: this.controller.searchResults.length, + })}" + ></h3>` + )} + <fxview-tab-list + compactRows + maxTabsLength="-1" + .searchQuery=${this.controller.searchQuery} + .tabItems=${this.getTabItems(this.controller.searchResults)} + @fxview-tab-list-primary-action=${this.onPrimaryAction} + .updatesPaused=${false} + > + </fxview-tab-list> + </div> + </moz-card>`; + } + + async onChangeSortOption(e) { + await this.controller.onChangeSortOption(e); + } + + async onSearchQuery(e) { + await this.controller.onSearchQuery(e); + } + + getTabItems(items) { + return items.map(item => ({ + ...item, + secondaryL10nId: null, + secondaryL10nArgs: null, + })); + } + + render() { + return html` + ${this.stylesheet()} + <div class="container"> + <div class="history-sort-option"> + <div class="history-sort-option"> + <fxview-search-textbox + data-l10n-id="firefoxview-search-text-box-history" + data-l10n-attrs="placeholder" + @fxview-search-textbox-query=${this.onSearchQuery} + .size=${15} + ></fxview-search-textbox> + </div> + </div> + ${this.cardsTemplate} + </div> + `; + } + + willUpdate() { + if (this.controller.allHistoryItems.size) { + // onChangeSortOption() will update history data once it has been fetched + // from the API. + this.controller.createHistoryMaps(); + } + } +} + +customElements.define("sidebar-history", SidebarHistory); diff --git a/browser/components/sidebar/sidebar-launcher.css b/browser/components/sidebar/sidebar-launcher.css new file mode 100644 index 0000000000..b033a650b3 --- /dev/null +++ b/browser/components/sidebar/sidebar-launcher.css @@ -0,0 +1,34 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +.wrapper { + display: grid; + grid-template-rows: auto 1fr; + box-sizing: border-box; + height: 100%; + padding: var(--space-medium); + border-inline-end: 1px solid var(--chrome-content-separator-color); + background-color: var(--sidebar-background-color); + color: var(--sidebar-text-color); + :host([positionend]) & { + border-inline-start: 1px solid var(--chrome-content-separator-color); + border-inline-end: none;; + } +} + +:host([positionend]) { + .wrapper { + border-inline-start: 1px solid var(--chrome-content-separator-color); + } +} + +.actions-list { + display: flex; + flex-direction: column; + justify-content: end; +} + +.icon-button::part(button) { + background-image: var(--action-icon); +} diff --git a/browser/components/sidebar/sidebar-launcher.mjs b/browser/components/sidebar/sidebar-launcher.mjs new file mode 100644 index 0000000000..85eb94b6ca --- /dev/null +++ b/browser/components/sidebar/sidebar-launcher.mjs @@ -0,0 +1,169 @@ +/* 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/. */ + +import { + html, + ifDefined, + styleMap, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; + +/** + * Vertical strip attached to the launcher that provides an entry point + * to various sidebar panels. + * + */ +export default class SidebarLauncher extends MozLitElement { + static properties = { + topActions: { type: Array }, + bottomActions: { type: Array }, + selectedView: { type: String }, + open: { type: Boolean }, + }; + + constructor() { + super(); + this.topActions = [ + { + icon: `url("chrome://browser/skin/insights.svg")`, + view: null, + l10nId: "sidebar-launcher-insights", + }, + ]; + + this.bottomActions = [ + { + l10nId: "sidebar-menu-history", + icon: `url("chrome://browser/content/firefoxview/view-history.svg")`, + view: "viewHistorySidebar", + }, + { + l10nId: "sidebar-menu-bookmarks", + icon: `url("chrome://browser/skin/bookmark-hollow.svg")`, + view: "viewBookmarksSidebar", + }, + { + l10nId: "sidebar-menu-synced-tabs", + icon: `url("chrome://browser/skin/device-phone.svg")`, + view: "viewTabsSidebar", + }, + ]; + + this.selectedView = window.SidebarUI.currentID; + this.open = window.SidebarUI.isOpen; + this.menuMutationObserver = new MutationObserver(() => + this.#setExtensionItems() + ); + } + + connectedCallback() { + super.connectedCallback(); + this._sidebarBox = document.getElementById("sidebar-box"); + this._sidebarBox.addEventListener("sidebar-show", this); + this._sidebarBox.addEventListener("sidebar-hide", this); + this._sidebarMenu = document.getElementById("viewSidebarMenu"); + + this.menuMutationObserver.observe(this._sidebarMenu, { + childList: true, + subtree: true, + }); + this.#setExtensionItems(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._sidebarBox.removeEventListener("sidebar-show", this); + this._sidebarBox.removeEventListener("sidebar-hide", this); + this.menuMutationObserver.disconnect(); + } + + getImageUrl(icon, targetURI) { + if (window.IS_STORYBOOK) { + return `chrome://global/skin/icons/defaultFavicon.svg`; + } + if (!icon) { + if (targetURI?.startsWith("moz-extension")) { + return "chrome://mozapps/skin/extensions/extension.svg"; + } + return `chrome://global/skin/icons/defaultFavicon.svg`; + } + // If the icon is not for website (doesn't begin with http), we + // display it directly. Otherwise we go through the page-icon + // protocol to try to get a cached version. We don't load + // favicons directly. + if (icon.startsWith("http")) { + return `page-icon:${targetURI}`; + } + return icon; + } + + #setExtensionItems() { + for (let item of this._sidebarMenu.children) { + if (item.id.endsWith("-sidebar-action")) { + this.topActions.push({ + tooltiptext: item.label, + icon: item.style.getPropertyValue("--webextension-menuitem-image"), + view: item.id.slice("menubar_menu_".length), + }); + } + } + } + + handleEvent(e) { + switch (e.type) { + case "sidebar-show": + this.selectedView = e.detail.viewId; + this.open = true; + break; + case "sidebar-hide": + this.open = false; + break; + } + } + + showView(e) { + let view = e.target.getAttribute("view"); + window.SidebarUI.toggle(view); + } + + buttonType(action) { + return this.open && action.view == this.selectedView + ? "icon" + : "icon ghost"; + } + + entrypointTemplate(action) { + return html`<moz-button + class="icon-button" + type=${this.buttonType(action)} + view=${action.view} + @click=${action.view ? this.showView : null} + title=${ifDefined(action.tooltiptext)} + data-l10n-id=${ifDefined(action.l10nId)} + style=${styleMap({ "--action-icon": action.icon })} + > + </moz-button>`; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/sidebar/sidebar-launcher.css" + /> + <div class="wrapper"> + <div class="top-actions actions-list"> + ${this.topActions.map(action => this.entrypointTemplate(action))} + </div> + <div class="bottom-actions actions-list"> + ${this.bottomActions.map(action => this.entrypointTemplate(action))} + </div> + </div> + `; + } +} +customElements.define("sidebar-launcher", SidebarLauncher); diff --git a/browser/components/sidebar/sidebar-page.mjs b/browser/components/sidebar/sidebar-page.mjs new file mode 100644 index 0000000000..157298a561 --- /dev/null +++ b/browser/components/sidebar/sidebar-page.mjs @@ -0,0 +1,45 @@ +/* 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/. */ + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { html } from "chrome://global/content/vendor/lit.all.mjs"; + +export class SidebarPage extends MozLitElement { + constructor() { + super(); + this.clearDocument = this.clearDocument.bind(this); + } + + connectedCallback() { + super.connectedCallback(); + this.ownerGlobal.addEventListener("beforeunload", this.clearDocument); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.ownerGlobal.removeEventListener("beforeunload", this.clearDocument); + } + + /** + * Clear out the document so the disconnectedCallback() will trigger properly + * and all of the custom elements can cleanup. + */ + clearDocument() { + this.ownerGlobal.document.body.textContent = ""; + } + + /** + * The common stylesheet for all sidebar pages. + * + * @returns {TemplateResult} + */ + stylesheet() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/sidebar/sidebar.css" + /> + `; + } +} diff --git a/browser/components/sidebar/sidebar-syncedtabs.html b/browser/components/sidebar/sidebar-syncedtabs.html new file mode 100644 index 0000000000..6c4874b9ea --- /dev/null +++ b/browser/components/sidebar/sidebar-syncedtabs.html @@ -0,0 +1,45 @@ +<!-- 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 resource: chrome:; object-src 'none'; img-src data: chrome:;" + /> + <meta name="color-scheme" content="light dark" /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="browser/firefoxView.ftl" /> + <link rel="localization" href="toolkit/branding/accounts.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <script + type="module" + src="chrome://global/content/elements/moz-card.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/fxview-empty-state.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/fxview-search-textbox.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/fxview-tab-list.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/sidebar/sidebar-syncedtabs.mjs" + ></script> + <script src="chrome://browser/content/contentTheme.js"></script> + </head> + + <body> + <sidebar-syncedtabs /> + </body> +</html> diff --git a/browser/components/sidebar/sidebar-syncedtabs.mjs b/browser/components/sidebar/sidebar-syncedtabs.mjs new file mode 100644 index 0000000000..4c3bd9dc46 --- /dev/null +++ b/browser/components/sidebar/sidebar-syncedtabs.mjs @@ -0,0 +1,191 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SyncedTabsController: "resource:///modules/SyncedTabsController.sys.mjs", +}); + +import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +import { + escapeHtmlEntities, + navigateToLink, +} from "chrome://browser/content/firefoxview/helpers.mjs"; + +import { SidebarPage } from "./sidebar-page.mjs"; + +class SyncedTabsInSidebar extends SidebarPage { + controller = new lazy.SyncedTabsController(this); + + constructor() { + super(); + this.onSearchQuery = this.onSearchQuery.bind(this); + } + + connectedCallback() { + super.connectedCallback(); + this.controller.addSyncObservers(); + this.controller.updateStates(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.controller.removeSyncObservers(); + } + + /** + * The template shown when the list of synced devices is currently + * unavailable. + * + * @param {object} options + * @param {string} options.action + * @param {string} options.buttonLabel + * @param {string[]} options.descriptionArray + * @param {string} options.descriptionLink + * @param {boolean} options.error + * @param {string} options.header + * @param {string} options.headerIconUrl + * @param {string} options.mainImageUrl + * @returns {TemplateResult} + */ + messageCardTemplate({ + action, + buttonLabel, + descriptionArray, + descriptionLink, + error, + header, + headerIconUrl, + mainImageUrl, + }) { + return html` + <fxview-empty-state + headerLabel=${header} + .descriptionLabels=${descriptionArray} + .descriptionLink=${ifDefined(descriptionLink)} + class="empty-state synced-tabs error" + isSelectedTab + mainImageUrl="${ifDefined(mainImageUrl)}" + ?errorGrayscale=${error} + headerIconUrl="${ifDefined(headerIconUrl)}" + id="empty-container" + > + <button + class="primary" + slot="primary-action" + ?hidden=${!buttonLabel} + data-l10n-id="${ifDefined(buttonLabel)}" + data-action="${action}" + @click=${e => this.controller.handleEvent(e)} + aria-details="empty-container" + ></button> + </fxview-empty-state> + `; + } + + /** + * The template shown for a device that has tabs. + * + * @param {string} deviceName + * @param {string} deviceType + * @param {Array} tabItems + * @returns {TemplateResult} + */ + deviceTemplate(deviceName, deviceType, tabItems) { + return html`<moz-card + type="accordion" + .heading=${deviceName} + icon + class=${deviceType} + > + <fxview-tab-list + compactRows + .tabItems=${ifDefined(tabItems)} + .updatesPaused=${false} + .searchQuery=${this.controller.searchQuery} + @fxview-tab-list-primary-action=${navigateToLink} + /> + </moz-card>`; + } + + /** + * The template shown for a device that has no tabs. + * + * @param {string} deviceName + * @param {string} deviceType + * @returns {TemplateResult} + */ + noDeviceTabsTemplate(deviceName, deviceType) { + return html`<moz-card + .heading=${deviceName} + icon + class=${deviceType} + data-l10n-id="firefoxview-syncedtabs-device-notabs" + > + </moz-card>`; + } + + /** + * The template shown for a device that has tabs, but no tabs that match the + * current search query. + * + * @param {string} deviceName + * @param {string} deviceType + * @returns {TemplateResult} + */ + noSearchResultsTemplate(deviceName, deviceType) { + return html`<moz-card + .heading=${deviceName} + icon + class=${deviceType} + data-l10n-id="firefoxview-search-results-empty" + data-l10n-args=${JSON.stringify({ + query: escapeHtmlEntities(this.controller.searchQuery), + })} + > + </moz-card>`; + } + + /** + * The template shown for the list of synced devices. + * + * @returns {TemplateResult[]} + */ + deviceListTemplate() { + return Object.values(this.controller.getRenderInfo()).map( + ({ name: deviceName, deviceType, tabItems, tabs }) => { + if (tabItems.length) { + return this.deviceTemplate(deviceName, deviceType, tabItems); + } else if (tabs.length) { + return this.noSearchResultsTemplate(deviceName, deviceType); + } + return this.noDeviceTabsTemplate(deviceName, deviceType); + } + ); + } + + render() { + const messageCard = this.controller.getMessageCard(); + if (messageCard) { + return [this.stylesheet(), this.messageCardTemplate(messageCard)]; + } + return html` + ${this.stylesheet()} + <fxview-search-textbox + data-l10n-id="firefoxview-search-text-box-syncedtabs" + data-l10n-attrs="placeholder" + @fxview-search-textbox-query=${this.onSearchQuery} + size="15" + ></fxview-search-textbox> + ${this.deviceListTemplate()} + `; + } + + onSearchQuery(e) { + this.controller.searchQuery = e.detail.query; + this.requestUpdate(); + } +} + +customElements.define("sidebar-syncedtabs", SyncedTabsInSidebar); diff --git a/browser/components/sidebar/sidebar.css b/browser/components/sidebar/sidebar.css new file mode 100644 index 0000000000..34d43aa850 --- /dev/null +++ b/browser/components/sidebar/sidebar.css @@ -0,0 +1,27 @@ +/* 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/. */ + +@import url("chrome://global/skin/global.css"); + +:root { + background-color: var(--lwt-sidebar-background-color); + color: var(--lwt-sidebar-text-color); +} + +moz-card { + margin-block-start: var(--space-medium); + + &.phone::part(icon), + &.mobile::part(icon) { + background-image: url('chrome://browser/skin/device-phone.svg'); + } + + &.desktop::part(icon) { + background-image: url('chrome://browser/skin/device-desktop.svg'); + } + + &.tablet::part(icon) { + background-image: url('chrome://browser/skin/device-tablet.svg'); + } +} diff --git a/browser/components/sidebar/sidebar.ftl b/browser/components/sidebar/sidebar.ftl new file mode 100644 index 0000000000..2a5ef75d83 --- /dev/null +++ b/browser/components/sidebar/sidebar.ftl @@ -0,0 +1,26 @@ +# 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/. + +sidebar-launcher-insights = + .title = Insights + +## Variables: +## $date (string) - Date to be formatted based on locale + +sidebar-history-date-today = + .heading = Today — { DATETIME($date, dateStyle: "full") } +sidebar-history-date-yesterday = + .heading = Yesterday — { DATETIME($date, dateStyle: "full") } +sidebar-history-date-this-month = + .heading = { DATETIME($date, dateStyle: "full") } +sidebar-history-date-prev-month = + .heading = { DATETIME($date, month: "long", year: "numeric") } + +## + +# "Search" is a noun (as in "Results of the search for") +# Variables: +# $query (String) - The search query used for searching through browser history. +sidebar-search-results-header = + .heading = Search results for “{ $query }” diff --git a/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs index 29b57812bd..51f1f49fa5 100644 --- a/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs +++ b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs @@ -10,7 +10,7 @@ import { PseudoLocalizationButton } from "../PseudoLocalizationButton.jsx"; import { FluentPanel } from "../FluentPanel.jsx"; // Register the addon. -addons.register(ADDON_ID, api => { +addons.register(ADDON_ID, () => { // Register the tool. addons.add(TOOL_ID, { type: types.TOOL, diff --git a/browser/components/storybook/.storybook/main.js b/browser/components/storybook/.storybook/main.js index 5791d1e492..0a5a55b851 100644 --- a/browser/components/storybook/.storybook/main.js +++ b/browser/components/storybook/.storybook/main.js @@ -13,17 +13,24 @@ const projectRoot = path.resolve(__dirname, "../../../../"); module.exports = { // The ordering for this stories array affects the order that they are displayed in Storybook stories: [ + // Show the Storybook document first in the list + // so that navigating to firefoxux.github.io/firefox-desktop-components/ + // lands on the Storybook.stories.md file + "../**/README.storybook.stories.md", // Docs section "../**/README.*.stories.md", // UI Widgets section `${projectRoot}/toolkit/content/widgets/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`, // about:logins components stories `${projectRoot}/browser/components/aboutlogins/content/components/**/*.stories.mjs`, + // Reader View components stories + `${projectRoot}/toolkit/components/reader/**/*.stories.mjs`, // Everything else "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx|md)", // Design system files `${projectRoot}/toolkit/themes/shared/design-system/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`, ], + staticDirs: [`${projectRoot}/toolkit/themes/shared/design-system/docs/`], addons: [ "@storybook/addon-links", { @@ -50,7 +57,7 @@ module.exports = { }; return [...existingIndexers, customIndexer]; }, - webpackFinal: async (config, { configType }) => { + webpackFinal: async config => { // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' // You can change the configuration based on that. // 'PRODUCTION' is used when building the static version of storybook. diff --git a/browser/components/storybook/.storybook/manager-head.html b/browser/components/storybook/.storybook/manager-head.html new file mode 100644 index 0000000000..380828d40b --- /dev/null +++ b/browser/components/storybook/.storybook/manager-head.html @@ -0,0 +1,22 @@ +<!-- 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/. --> + +<link + rel="stylesheet" + href="chrome://global/skin/design-system/tokens-brand.css" +/> + +<style> + /* light-dark doesn't work here, using media queries */ + @media (prefers-color-scheme: light) { + iframe { + background-color: var(--color-white) !important; + } + } + @media (prefers-color-scheme: dark) { + iframe { + background-color: var(--color-gray-90) !important; + } + } +</style> diff --git a/browser/components/storybook/.storybook/markdown-story-utils.js b/browser/components/storybook/.storybook/markdown-story-utils.js index 1cc78164ad..5926c5593c 100644 --- a/browser/components/storybook/.storybook/markdown-story-utils.js +++ b/browser/components/storybook/.storybook/markdown-story-utils.js @@ -121,19 +121,21 @@ function getStoryTitle(resourcePath) { * @returns Path used to import a component into a story. */ function getImportPath(resourcePath) { + // We need to normalize the path for this logic to work cross-platform. + let normalizedPath = resourcePath.split(path.sep).join("/"); // Limiting this to toolkit widgets for now since we don't have any // interactive examples in other docs stories. - if (!resourcePath.includes("toolkit/content/widgets")) { + if (!normalizedPath.includes("toolkit/content/widgets")) { return ""; } - let componentName = getComponentName(resourcePath); + let componentName = getComponentName(normalizedPath); let fileExtension = ""; if (componentName) { - let mjsPath = resourcePath.replace( + let mjsPath = normalizedPath.replace( "README.stories.md", `${componentName}.mjs` ); - let jsPath = resourcePath.replace( + let jsPath = normalizedPath.replace( "README.stories.md", `${componentName}.js` ); diff --git a/browser/components/storybook/.storybook/preview-head.html b/browser/components/storybook/.storybook/preview-head.html index 206972e714..ae9d8fdf5a 100644 --- a/browser/components/storybook/.storybook/preview-head.html +++ b/browser/components/storybook/.storybook/preview-head.html @@ -37,8 +37,12 @@ } /* Ensure WithCommonStyles can grow to fit the page */ - #root-inner { - height: 100vh; + html, + body, + #root, + #root-inner, + #storybook-root { + height: 100%; } /* Docs stories are being given unnecessary height, possibly because we diff --git a/browser/components/storybook/.storybook/preview.mjs b/browser/components/storybook/.storybook/preview.mjs index 4e0f3f407d..ec7fd42151 100644 --- a/browser/components/storybook/.storybook/preview.mjs +++ b/browser/components/storybook/.storybook/preview.mjs @@ -54,7 +54,7 @@ class WithCommonStyles extends MozLitElement { font: message-box; font-size: var(--font-size-root); appearance: none; - background-color: var(--color-canvas); + background-color: var(--background-color-canvas); color: var(--text-color); -moz-box-layout: flex; } @@ -113,6 +113,7 @@ export default { title: "On this page", }, }, + options: { showPanel: true }, }, }; diff --git a/browser/components/storybook/docs/README.storybook.stories.md b/browser/components/storybook/docs/README.storybook.stories.md index bb0fcdd1a2..5e94be7761 100644 --- a/browser/components/storybook/docs/README.storybook.stories.md +++ b/browser/components/storybook/docs/README.storybook.stories.md @@ -4,7 +4,7 @@ playground for UI components. We use Storybook to document our design system, reusable components, and any specific components you might want to test with dummy data. [Take a look at our Storybook -instance!](https://firefoxux.github.io/firefox-desktop-components/?path=/story/docs-reusable-widgets--page) +instance!](https://firefoxux.github.io/firefox-desktop-components/) ## Background diff --git a/browser/components/storybook/stories/fxview-tab-list.stories.mjs b/browser/components/storybook/stories/fxview-tab-list.stories.mjs index c8e2328c44..b18ad16e3a 100644 --- a/browser/components/storybook/stories/fxview-tab-list.stories.mjs +++ b/browser/components/storybook/stories/fxview-tab-list.stories.mjs @@ -84,7 +84,7 @@ let secondaryAction = e => { e.target.querySelector("panel-list").toggle(e.detail.originalEvent); }; -let primaryAction = e => { +let primaryAction = () => { // Open in new tab }; diff --git a/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs index 47571f789d..5fe9c8c3e5 100644 --- a/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs +++ b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs @@ -96,7 +96,7 @@ SyncedTabsDeckComponent.prototype = { this._deckView.destroy(); }, - observe(subject, topic, data) { + observe(subject, topic) { switch (topic) { case this._SyncedTabs.TOPIC_TABS_CHANGED: this._syncedTabsListStore.getData(); diff --git a/browser/components/syncedtabs/TabListView.sys.mjs b/browser/components/syncedtabs/TabListView.sys.mjs index 041d7300d9..70c98c4175 100644 --- a/browser/components/syncedtabs/TabListView.sys.mjs +++ b/browser/components/syncedtabs/TabListView.sys.mjs @@ -127,7 +127,7 @@ TabListView.prototype = { }, // Client rows are hidden when the list is filtered - _renderFilteredClient(client, filter) { + _renderFilteredClient(client) { client.tabs.forEach((tab, index) => { let node = this._renderTab(client, tab, index); this.list.appendChild(node); diff --git a/browser/components/tabpreview/jar.mn b/browser/components/tabpreview/jar.mn index 8ff09ebb17..589bc71430 100644 --- a/browser/components/tabpreview/jar.mn +++ b/browser/components/tabpreview/jar.mn @@ -3,5 +3,5 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. browser.jar: - content/browser/tabpreview/tabpreview.mjs (tabpreview.mjs) + content/browser/tabpreview/tab-preview-panel.mjs (tab-preview-panel.mjs) content/browser/tabpreview/tabpreview.css (tabpreview.css) diff --git a/browser/components/tabpreview/tab-preview-panel.mjs b/browser/components/tabpreview/tab-preview-panel.mjs new file mode 100644 index 0000000000..683b2c17ec --- /dev/null +++ b/browser/components/tabpreview/tab-preview-panel.mjs @@ -0,0 +1,174 @@ +/* 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/. */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const POPUP_OPTIONS = { + position: "bottomleft topleft", + x: 0, + y: -2, +}; + +/** + * Detailed preview card that displays when hovering a tab + */ +export default class TabPreviewPanel { + constructor(panel) { + this._panel = panel; + this._win = panel.ownerGlobal; + this._tab = null; + this._thumbnailElement = null; + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_prefDisableAutohide", + "ui.popup.disable_autohide", + false + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_prefPreviewDelay", + "ui.tooltip.delay_ms" + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_prefDisplayThumbnail", + "browser.tabs.cardPreview.showThumbnails", + false + ); + this._timer = null; + } + + getPrettyURI(uri) { + try { + const url = new URL(uri); + return `${url.hostname}`.replace(/^w{3}\./, ""); + } catch { + return uri; + } + } + + _needsThumbnailFor(tab) { + return !tab.selected; + } + + _maybeRequestThumbnail() { + if (!this._prefDisplayThumbnail) { + return; + } + if (!this._needsThumbnailFor(this._tab)) { + return; + } + let tab = this._tab; + this._win.tabPreviews.get(tab).then(el => { + if (this._tab == tab && this._needsThumbnailFor(tab)) { + this._thumbnailElement = el; + this._updatePreview(); + } + }); + } + + activate(tab) { + this._tab = tab; + this._thumbnailElement = null; + this._maybeRequestThumbnail(); + if (this._panel.state == "open") { + this._updatePreview(); + } + if (this._timer) { + return; + } + this._timer = this._win.setTimeout(() => { + this._timer = null; + this._panel.openPopup(this._tab, POPUP_OPTIONS); + }, this._prefPreviewDelay); + this._win.addEventListener("TabSelect", this); + this._panel.addEventListener("popupshowing", this); + } + + deactivate(leavingTab = null) { + if (leavingTab) { + if (this._tab != leavingTab) { + return; + } + this._win.requestAnimationFrame(() => { + if (this._tab == leavingTab) { + this.deactivate(); + } + }); + return; + } + this._tab = null; + this._thumbnailElement = null; + this._panel.removeEventListener("popupshowing", this); + this._win.removeEventListener("TabSelect", this); + if (!this._prefDisableAutohide) { + this._panel.hidePopup(); + } + if (this._timer) { + this._win.clearTimeout(this._timer); + this._timer = null; + } + } + + handleEvent(e) { + switch (e.type) { + case "popupshowing": + this._updatePreview(); + break; + case "TabSelect": + if (this._thumbnailElement && !this._needsThumbnailFor(this._tab)) { + this._thumbnailElement.remove(); + this._thumbnailElement = null; + } + break; + } + } + + _updatePreview() { + this._panel.querySelector(".tab-preview-title").textContent = + this._displayTitle; + this._panel.querySelector(".tab-preview-uri").textContent = + this._displayURI; + let thumbnailContainer = this._panel.querySelector( + ".tab-preview-thumbnail-container" + ); + if (thumbnailContainer.firstChild != this._thumbnailElement) { + thumbnailContainer.replaceChildren(); + if (this._thumbnailElement) { + thumbnailContainer.appendChild(this._thumbnailElement); + } + this._panel.dispatchEvent( + new CustomEvent("previewThumbnailUpdated", { + detail: { + thumbnail: this._thumbnailElement, + }, + }) + ); + } + if (this._tab && this._panel.state == "open") { + this._panel.moveToAnchor( + this._tab, + POPUP_OPTIONS.position, + POPUP_OPTIONS.x, + POPUP_OPTIONS.y + ); + } + } + + get _displayTitle() { + if (!this._tab) { + return ""; + } + return this._tab.textLabel.textContent; + } + + get _displayURI() { + if (!this._tab) { + return ""; + } + return this.getPrettyURI(this._tab.linkedBrowser.currentURI.spec); + } +} diff --git a/browser/components/tabpreview/tabpreview.css b/browser/components/tabpreview/tabpreview.css index 776f520c7d..e978266e5d 100644 --- a/browser/components/tabpreview/tabpreview.css +++ b/browser/components/tabpreview/tabpreview.css @@ -2,32 +2,21 @@ * 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/. */ -.tab-preview-container { - --tab-preview-background-color: light-dark(#fff, #42414d); - --tab-preview-text-color: light-dark(#15141a, #fbfbfe); - --tab-preview-border-color: light-dark(#cfcfd8, #8f8f9d); - - @media (prefers-contrast) { - --tab-preview-background-color: Canvas; - --tab-preview-text-color: CanvasText; - } +#tab-preview-panel { + --panel-width: 280px; + --panel-padding: 0; + --panel-background: var(--tab-selected-bgcolor); + --panel-color: var(--tab-selected-textcolor); } -.tab-preview-container { - background-color: var(--tab-preview-background-color); - color: var(--tab-preview-text-color); - border-radius: 9px; - display: inline-block; - width: 280px; - overflow: hidden; - line-height: 1.5; - border: 1px solid var(--tab-preview-border-color); +.tab-preview-text-container { + padding: var(--space-small); } .tab-preview-title { max-height: 3em; overflow: hidden; - font-weight: 600; + font-weight: var(--font-weight-bold); } .tab-preview-uri { @@ -38,22 +27,18 @@ text-overflow: ellipsis; } -.tab-preview-text-container { - padding: 8px; -} - .tab-preview-thumbnail-container { - border-top: 1px solid var(--tab-preview-border-color); -} - -.tab-preview-thumbnail-container img, -.tab-preview-thumbnail-container canvas { - display: block; - width: 100%; -} - -@media (max-width: 640px) { - .tab-preview-thumbnail-container { + border-top: 1px solid var(--panel-border-color); + &:empty { display: none; } + @media (width < 640px) { + display: none; + } + + > img, + > canvas { + display: block; + width: 100%; + } } diff --git a/browser/components/tabpreview/tabpreview.mjs b/browser/components/tabpreview/tabpreview.mjs deleted file mode 100644 index 2409c3fa7a..0000000000 --- a/browser/components/tabpreview/tabpreview.mjs +++ /dev/null @@ -1,237 +0,0 @@ -/* 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/. */ - -import { html } from "chrome://global/content/vendor/lit.all.mjs"; -import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; - -var { XPCOMUtils } = ChromeUtils.importESModule( - "resource://gre/modules/XPCOMUtils.sys.mjs" -); - -const TAB_PREVIEW_USE_THUMBNAILS_PREF = - "browser.tabs.cardPreview.showThumbnails"; - -/** - * Detailed preview card that displays when hovering a tab - * - * @property {MozTabbrowserTab} tab - the tab to preview - * @fires TabPreview#previewhidden - * @fires TabPreview#previewshown - * @fires TabPreview#previewThumbnailUpdated - */ -export default class TabPreview extends MozLitElement { - static properties = { - tab: { type: Object }, - - _previewIsActive: { type: Boolean, state: true }, - _previewDelayTimeout: { type: Number, state: true }, - _displayTitle: { type: String, state: true }, - _displayURI: { type: String, state: true }, - _displayImg: { type: Object, state: true }, - }; - - constructor() { - super(); - XPCOMUtils.defineLazyPreferenceGetter( - this, - "_prefPreviewDelay", - "ui.tooltip.delay_ms" - ); - XPCOMUtils.defineLazyPreferenceGetter( - this, - "_prefDisplayThumbnail", - TAB_PREVIEW_USE_THUMBNAILS_PREF, - false - ); - } - - // render this inside a <panel> - createRenderRoot() { - if (!document.createXULElement) { - console.error( - "Unable to create panel: document.createXULElement is not available" - ); - return super.createRenderRoot(); - } - this.attachShadow({ mode: "open" }); - this.panel = document.createXULElement("panel"); - this.panel.setAttribute("id", "tabPreviewPanel"); - this.panel.setAttribute("noautofocus", true); - this.panel.setAttribute("norolluponanchor", true); - this.panel.setAttribute("consumeoutsideclicks", "never"); - this.panel.setAttribute("rolluponmousewheel", "true"); - this.panel.setAttribute("level", "parent"); - this.shadowRoot.append(this.panel); - return this.panel; - } - - get previewCanShow() { - return this._previewIsActive && this.tab; - } - - get thumbnailCanShow() { - return ( - this.previewCanShow && - this._prefDisplayThumbnail && - !this.tab.selected && - this._displayImg - ); - } - - getPrettyURI(uri) { - try { - const url = new URL(uri); - return `${url.hostname}`.replace(/^w{3}\./, ""); - } catch { - return uri; - } - } - - handleEvent(e) { - switch (e.type) { - case "TabSelect": { - this.requestUpdate(); - break; - } - case "popuphidden": { - this.previewHidden(); - break; - } - } - } - - showPreview() { - this.panel.openPopup(this.tab, { - position: "bottomleft topleft", - y: -2, - isContextMenu: false, - }); - window.addEventListener("TabSelect", this); - this.panel.addEventListener("popuphidden", this); - } - - hidePreview() { - this.panel.hidePopup(); - } - - previewHidden() { - window.removeEventListener("TabSelect", this); - this.panel.removeEventListener("popuphidden", this); - - /** - * @event TabPreview#previewhidden - * @type {CustomEvent} - */ - this.dispatchEvent(new CustomEvent("previewhidden")); - } - - // compute values derived from tab element - willUpdate(changedProperties) { - if (!changedProperties.has("tab")) { - return; - } - if (!this.tab) { - this._displayTitle = ""; - this._displayURI = ""; - this._displayImg = null; - return; - } - this._displayTitle = this.tab.textLabel.textContent; - this._displayURI = this.getPrettyURI( - this.tab.linkedBrowser.currentURI.spec - ); - this._displayImg = null; - let { tab } = this; - window.tabPreviews.get(this.tab).then(el => { - if (this.tab == tab) { - this._displayImg = el; - } - }); - } - - updated(changedProperties) { - if (changedProperties.has("tab")) { - // handle preview delay - if (!this.tab) { - clearTimeout(this._previewDelayTimeout); - this._previewIsActive = false; - } else { - let lastTabVal = changedProperties.get("tab"); - if (!lastTabVal) { - // tab was set from an empty state, - // so wait for the delay duration before showing - this._previewDelayTimeout = setTimeout(() => { - this._previewIsActive = true; - }, this._prefPreviewDelay); - } - } - } - if (changedProperties.has("_previewIsActive")) { - if (!this._previewIsActive) { - this.hidePreview(); - } - } - if ( - (changedProperties.has("tab") || - changedProperties.has("_previewIsActive")) && - this.previewCanShow - ) { - this.updateComplete.then(() => { - if (this.panel.state == "open" || this.panel.state == "showing") { - this.panel.moveToAnchor(this.tab, "bottomleft topleft", 0, -2); - } else { - this.showPreview(); - } - - this.dispatchEvent( - /** - * @event TabPreview#previewshown - * @type {CustomEvent} - * @property {object} detail - * @property {MozTabbrowserTab} detail.tab - the tab being previewed - */ - new CustomEvent("previewshown", { - detail: { tab: this.tab }, - }) - ); - }); - } - if (changedProperties.has("_displayImg")) { - this.updateComplete.then(() => { - /** - * fires when the thumbnail for a preview is loaded - * and added to the document. - * - * @event TabPreview#previewThumbnailUpdated - * @type {CustomEvent} - */ - this.dispatchEvent(new CustomEvent("previewThumbnailUpdated")); - }); - } - } - - render() { - return html` - <link - rel="stylesheet" - type="text/css" - href="chrome://browser/content/tabpreview/tabpreview.css" - /> - <div class="tab-preview-container"> - <div class="tab-preview-text-container"> - <div class="tab-preview-title">${this._displayTitle}</div> - <div class="tab-preview-uri">${this._displayURI}</div> - </div> - ${this.thumbnailCanShow - ? html` - <div class="tab-preview-thumbnail-container"> - ${this._displayImg} - </div> - ` - : ""} - </div> - `; - } -} -customElements.define("tab-preview", TabPreview); diff --git a/browser/components/tests/browser/browser_contentpermissionprompt.js b/browser/components/tests/browser/browser_contentpermissionprompt.js index 3e2eb24f62..11b18a6653 100644 --- a/browser/components/tests/browser/browser_contentpermissionprompt.js +++ b/browser/components/tests/browser/browser_contentpermissionprompt.js @@ -151,7 +151,7 @@ add_task(async function test_working_request() { }, }; - let integration = base => ({ + let integration = () => ({ createPermissionPrompt(type, request) { Assert.equal(type, "test-permission-type"); Assert.ok( diff --git a/browser/components/tests/browser/browser_default_webprotocol_handler_mailto.js b/browser/components/tests/browser/browser_default_webprotocol_handler_mailto.js index d061d84b23..7bb8b22a98 100644 --- a/browser/components/tests/browser/browser_default_webprotocol_handler_mailto.js +++ b/browser/components/tests/browser/browser_default_webprotocol_handler_mailto.js @@ -11,12 +11,43 @@ add_setup(function add_setup() { Services.prefs.setBoolPref("browser.mailto.dualPrompt", true); }); +/* helper function to delete site specific settings needed to clean up + * the testing setup after some of these tests. + * + * @see: nsIContentPrefService2.idl + */ +function _deleteSiteSpecificSetting(domain, setting, context = null) { + const contentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( + Ci.nsIContentPrefService2 + ); + + return contentPrefs.removeByDomainAndName(domain, setting, context, { + handleResult(_) {}, + handleCompletion() { + Assert.ok(true, "setting successfully deleted."); + }, + handleError(_) { + Assert.ok(false, "could not delete site specific setting."); + }, + }); +} + // site specific settings const protocol = "mailto"; -const sss_domain = "test.example.com"; +const subdomain = (Math.random() + 1).toString(36).substring(7); +const sss_domain = subdomain + ".example.com"; const ss_setting = "system.under.test"; add_task(async function check_null_value() { + Assert.ok( + /[a-z0-9].+\.[a-z]+\.[a-z]+/.test(sss_domain), + "test the validity of this random domain name before using it for tests: '" + + sss_domain + + "'" + ); +}); + +add_task(async function check_null_value() { Assert.equal( null, await WebProtocolHandlerRegistrar._getSiteSpecificSetting( @@ -46,13 +77,31 @@ add_task(async function check_save_value() { ss_setting, ss_setting ); + + let fetchedSiteSpecificSetting; + try { + fetchedSiteSpecificSetting = + await WebProtocolHandlerRegistrar._getSiteSpecificSetting( + sss_domain, + ss_setting + ); + } finally { + // make sure the cleanup happens, no matter what + _deleteSiteSpecificSetting(sss_domain, ss_setting); + } Assert.equal( ss_setting, + fetchedSiteSpecificSetting, + "site specific setting save and retrieve test." + ); + + Assert.equal( + null, await WebProtocolHandlerRegistrar._getSiteSpecificSetting( sss_domain, ss_setting ), - "site specific setting save and retrieve test." + "site specific setting should not exist after delete." ); }); diff --git a/browser/components/tests/browser/browser_quit_disabled.js b/browser/components/tests/browser/browser_quit_disabled.js index 3b7e99a1bf..a2151e8953 100644 --- a/browser/components/tests/browser/browser_quit_disabled.js +++ b/browser/components/tests/browser/browser_quit_disabled.js @@ -27,7 +27,7 @@ add_task(async function test_quit_shortcut_disabled() { let quitRequested = false; let observer = { - observe(subject, topic, data) { + observe(subject, topic) { is(topic, "quit-application-requested", "Right observer topic"); ok(shouldQuit, "Quit shortcut should NOT have worked"); diff --git a/browser/components/tests/browser/head.js b/browser/components/tests/browser/head.js index 89c8df8613..28b14aef0b 100644 --- a/browser/components/tests/browser/head.js +++ b/browser/components/tests/browser/head.js @@ -42,7 +42,7 @@ function mockShell(overrides = {}) { isDefault: false, isPinned: false, - async checkPinCurrentAppToTaskbarAsync(privateBrowsing = false) { + async checkPinCurrentAppToTaskbarAsync() { if (!this.canPin) { throw Error; } @@ -50,7 +50,7 @@ function mockShell(overrides = {}) { get isAppInDock() { return this.isPinned; }, - isCurrentAppPinnedToTaskbarAsync(privateBrowsing = false) { + isCurrentAppPinnedToTaskbarAsync() { return Promise.resolve(this.isPinned); }, isDefaultBrowser() { diff --git a/browser/components/touchbar/MacTouchBar.sys.mjs b/browser/components/touchbar/MacTouchBar.sys.mjs index 5b598efc00..6588920f5e 100644 --- a/browser/components/touchbar/MacTouchBar.sys.mjs +++ b/browser/components/touchbar/MacTouchBar.sys.mjs @@ -107,7 +107,7 @@ var gBuiltInInputs = { type: kInputTypes.BUTTON, callback: () => { let win = lazy.BrowserWindowTracker.getTopWindow(); - win.BrowserHome(); + win.BrowserCommands.home(); }, }, Fullscreen: { diff --git a/browser/components/touchbar/tests/browser/browser_touchbar_tests.js b/browser/components/touchbar/tests/browser/browser_touchbar_tests.js index c6326b4509..0da72143d1 100644 --- a/browser/components/touchbar/tests/browser/browser_touchbar_tests.js +++ b/browser/components/touchbar/tests/browser/browser_touchbar_tests.js @@ -139,7 +139,7 @@ function waitForFullScreenState(browser, state) { return new Promise(resolve => { let eventReceived = false; - let observe = (subject, topic, data) => { + let observe = () => { if (!eventReceived) { return; } diff --git a/browser/components/translations/content/TranslationsPanelShared.sys.mjs b/browser/components/translations/content/TranslationsPanelShared.sys.mjs index 570528df3f..f5045f57e0 100644 --- a/browser/components/translations/content/TranslationsPanelShared.sys.mjs +++ b/browser/components/translations/content/TranslationsPanelShared.sys.mjs @@ -11,9 +11,53 @@ ChromeUtils.defineESModuleGetters(lazy, { /** * A class containing static functionality that is shared by both * the FullPageTranslationsPanel and SelectTranslationsPanel classes. + * + * It is recommended to read the documentation above the TranslationsParent class + * definition to understand the scope of the Translations architecture throughout + * Firefox. + * + * @see TranslationsParent + * + * The static instance of this class is a singleton in the parent process, and is + * available throughout all windows and tabs, just like the static instance of + * the TranslationsParent class. + * + * Unlike the TranslationsParent, this class is never instantiated as an actor + * outside of the static-context functionality defined below. */ export class TranslationsPanelShared { - static #langListsInitState = new Map(); + /** + * A map from Translations Panel instances to their initialized states. + * There is one instance of each panel per top ChromeWindow in Firefox. + * + * See the documentation above the TranslationsParent class for a detailed + * explanation of the translations architecture throughout Firefox. + * + * @see TranslationsParent + * + * @type {Map<FullPageTranslationsPanel | SelectTranslationsPanel, string>} + */ + static #langListsInitState = new WeakMap(); + + /** + * True if the next language-list initialization to fail for testing. + * + * @see TranslationsPanelShared.ensureLangListsBuilt + * + * @type {boolean} + */ + static #simulateLangListError = false; + + /** + * Clears cached data regarding the initialization state of the + * FullPageTranslationsPanel or the SelectTranslationsPanel. + * + * This is only needed for test runners to ensure that each test + * starts from a clean slate. + */ + static clearCache() { + this.#langListsInitState = new WeakMap(); + } /** * Defines lazy getters for accessing elements in the document based on provided entries. @@ -46,13 +90,25 @@ export class TranslationsPanelShared { } /** + * Ensures that the next call to ensureLangListBuilt wil fail + * for the purpose of testing the error state. + * + * @see TranslationsPanelShared.ensureLangListsBuilt + * + * @type {boolean} + */ + static simulateLangListError() { + this.#simulateLangListError = true; + } + + /** * Retrieves the initialization state of language lists for the specified panel. * * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel * - The panel for which to look up the state. */ static getLangListsInitState(panel) { - return TranslationsPanelShared.#langListsInitState.get(panel.id); + return TranslationsPanelShared.#langListsInitState.get(panel); } /** @@ -64,17 +120,17 @@ export class TranslationsPanelShared { * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel * - The panel for which to ensure language lists are built. */ - static async ensureLangListsBuilt(document, panel, innerWindowId) { - const { id } = panel; - switch ( - TranslationsPanelShared.#langListsInitState.get(`${id}-${innerWindowId}`) - ) { + static async ensureLangListsBuilt(document, panel) { + const { panel: panelElement } = panel.elements; + switch (TranslationsPanelShared.#langListsInitState.get(panel)) { case "initialized": // This has already been initialized. return; case "error": case undefined: - // attempt to initialize + // Set the error state in case there is an early exit at any point. + // This will be set to "initialized" if everything succeeds. + TranslationsPanelShared.#langListsInitState.set(panel, "error"); break; default: throw new Error( @@ -88,18 +144,28 @@ export class TranslationsPanelShared { await lazy.TranslationsParent.getSupportedLanguages(); // Verify that we are in a proper state. - if (languagePairs.length === 0) { + if (languagePairs.length === 0 || this.#simulateLangListError) { + this.#simulateLangListError = false; throw new Error("No translation languages were retrieved."); } - const fromPopups = panel.querySelectorAll( + const fromPopups = panelElement.querySelectorAll( ".translations-panel-language-menupopup-from" ); - const toPopups = panel.querySelectorAll( + const toPopups = panelElement.querySelectorAll( ".translations-panel-language-menupopup-to" ); for (const popup of fromPopups) { + // For the moment, the FullPageTranslationsPanel includes its own + // menu item for "Choose another language" as the first item in the list + // with an empty-string for its value. The SelectTranslationsPanel has + // only languages in its list with BCP-47 tags for values. As such, + // this loop works for both panels, to remove all of the languages + // from the list, but ensuring that any empty-string items are retained. + while (popup.lastChild?.value) { + popup.lastChild.remove(); + } for (const { langTag, displayName } of fromLanguages) { const fromMenuItem = document.createXULElement("menuitem"); fromMenuItem.setAttribute("value", langTag); @@ -109,6 +175,9 @@ export class TranslationsPanelShared { } for (const popup of toPopups) { + while (popup.lastChild?.value) { + popup.lastChild.remove(); + } for (const { langTag, displayName } of toLanguages) { const toMenuItem = document.createXULElement("menuitem"); toMenuItem.setAttribute("value", langTag); @@ -117,6 +186,6 @@ export class TranslationsPanelShared { } } - TranslationsPanelShared.#langListsInitState.set(id, "initialized"); + TranslationsPanelShared.#langListsInitState.set(panel, "initialized"); } } diff --git a/browser/components/translations/content/fullPageTranslationsPanel.js b/browser/components/translations/content/fullPageTranslationsPanel.js index 2e35440160..eddd3566f1 100644 --- a/browser/components/translations/content/fullPageTranslationsPanel.js +++ b/browser/components/translations/content/fullPageTranslationsPanel.js @@ -188,12 +188,19 @@ class CheckboxPageAction { } /** - * This singleton class controls the Translations popup panel. + * This singleton class controls the FullPageTranslations panel. * * This component is a `/browser` component, and the actor is a `/toolkit` actor, so care * must be taken to keep the presentation (this component) from the state management * (the Translations actor). This class reacts to state changes coming from the * Translations actor. + * + * A global instance of this class is created once per top ChromeWindow and is initialized + * when the new window is created. + * + * See the comment above TranslationsParent for more details. + * + * @see TranslationsParent */ var FullPageTranslationsPanel = new (class { /** @type {Console?} */ @@ -374,21 +381,6 @@ var FullPageTranslationsPanel = new (class { } /** - * @returns {TranslationsParent} - */ - #getTranslationsActor() { - const actor = - gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( - "Translations" - ); - - if (!actor) { - throw new Error("Unable to get the TranslationsParent"); - } - return actor; - } - - /** * Fetches the language tags for the document and the user and caches the results * Use `#getCachedDetectedLanguages` when the lang tags do not need to be re-fetched. * This requires a bit of work to do, so prefer the cached version when possible. @@ -396,8 +388,9 @@ var FullPageTranslationsPanel = new (class { * @returns {Promise<LangTags>} */ async #fetchDetectedLanguages() { - this.detectedLanguages = - await this.#getTranslationsActor().getDetectedLanguages(); + this.detectedLanguages = await TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).getDetectedLanguages(); return this.detectedLanguages; } @@ -421,11 +414,7 @@ var FullPageTranslationsPanel = new (class { */ async #ensureLangListsBuilt() { try { - await TranslationsPanelShared.ensureLangListsBuilt( - document, - this.elements.panel, - gBrowser.selectedBrowser.innerWindowID - ); + await TranslationsPanelShared.ensureLangListsBuilt(document, this); } catch (error) { this.console?.error(error); } @@ -438,7 +427,9 @@ var FullPageTranslationsPanel = new (class { * @param {TranslationsLanguageState} languageState */ #updateViewFromTranslationStatus( - languageState = this.#getTranslationsActor().languageState + languageState = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).languageState ) { const { translateButton, toMenuList, fromMenuList, header, cancelButton } = this.elements; @@ -553,7 +544,7 @@ var FullPageTranslationsPanel = new (class { // Unconditionally hide the intro text in case the panel is re-shown. intro.hidden = true; - if (TranslationsPanelShared.getLangListsInitState(panel) === "error") { + if (TranslationsPanelShared.getLangListsInitState(this) === "error") { // There was an error, display it in the view rather than the language // dropdowns. const { cancelButton, errorHintAction } = this.elements; @@ -722,8 +713,9 @@ var FullPageTranslationsPanel = new (class { const neverTranslateSiteMenuItems = panel.ownerDocument.querySelectorAll( ".never-translate-site-menuitem" ); - const neverTranslateSite = - await this.#getTranslationsActor().shouldNeverTranslateSite(); + const neverTranslateSite = await TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).shouldNeverTranslateSite(); for (const menuitem of neverTranslateSiteMenuItems) { menuitem.setAttribute("checked", neverTranslateSite ? "true" : "false"); @@ -801,7 +793,9 @@ var FullPageTranslationsPanel = new (class { async #showRevisitView({ fromLanguage, toLanguage }) { const { fromMenuList, toMenuList, intro } = this.elements; if (!this.#isShowingDefaultView()) { - await this.#showDefaultView(this.#getTranslationsActor()); + await this.#showDefaultView( + TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser) + ); } intro.hidden = true; fromMenuList.value = fromLanguage; @@ -897,7 +891,7 @@ var FullPageTranslationsPanel = new (class { PanelMultiView.hidePopup(panel); await this.#showDefaultView( - this.#getTranslationsActor(), + TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser), true /* force this view to be shown */ ); @@ -1119,8 +1113,10 @@ var FullPageTranslationsPanel = new (class { const { button } = this.buttonElements; - const { requestedTranslationPair, locationChangeId } = - this.#getTranslationsActor().languageState; + const { requestedTranslationPair } = + TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).languageState; // Store this value because it gets modified when #showDefaultView is called below. const isFirstUserInteraction = !this._hasShownPanel; @@ -1132,7 +1128,9 @@ var FullPageTranslationsPanel = new (class { this.console?.error(error); }); } else { - await this.#showDefaultView(this.#getTranslationsActor()).catch(error => { + await this.#showDefaultView( + TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser) + ).catch(error => { this.console?.error(error); }); } @@ -1145,16 +1143,6 @@ var FullPageTranslationsPanel = new (class { ? button : this.elements.appMenuButton; - if (!TranslationsParent.isActiveLocation(locationChangeId)) { - this.console?.log(`A translation panel open request was stale.`, { - locationChangeId, - newlocationChangeId: - this.#getTranslationsActor().languageState.locationChangeId, - currentURISpec: gBrowser.currentURI.spec, - }); - return; - } - this.console?.log(`Showing a translation panel`, gBrowser.currentURI.spec); await this.#openPanelPopup(targetButton, { @@ -1173,7 +1161,9 @@ var FullPageTranslationsPanel = new (class { */ #isTranslationsActive() { const { requestedTranslationPair } = - this.#getTranslationsActor().languageState; + TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).languageState; return requestedTranslationPair !== null; } @@ -1183,7 +1173,9 @@ var FullPageTranslationsPanel = new (class { async onTranslate() { PanelMultiView.hidePopup(this.elements.panel); - const actor = this.#getTranslationsActor(); + const actor = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ); actor.translate( this.elements.fromMenuList.value, this.elements.toMenuList.value, @@ -1205,7 +1197,7 @@ var FullPageTranslationsPanel = new (class { this.#updateSettingsMenuLanguageCheckboxStates(); this.#updateSettingsMenuSiteCheckboxStates(); const popup = button.ownerDocument.getElementById( - "translations-panel-settings-menupopup" + "full-page-translations-panel-settings-menupopup" ); popup.openPopup(button, "after_end"); } @@ -1331,8 +1323,9 @@ var FullPageTranslationsPanel = new (class { */ async onNeverTranslateSite() { const pageAction = this.getCheckboxPageActionFor().neverTranslateSite(); - const toggledOn = - await this.#getTranslationsActor().toggleNeverTranslateSitePermissions(); + const toggledOn = await TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).toggleNeverTranslateSitePermissions(); TranslationsParent.telemetry().panel().onNeverTranslateSite(toggledOn); this.#updateSettingsMenuSiteCheckboxStates(); await this.#doPageAction(pageAction); @@ -1349,7 +1342,9 @@ var FullPageTranslationsPanel = new (class { throw new Error("Expected to have a document language tag."); } - this.#getTranslationsActor().restorePage(docLangTag); + TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).restorePage(docLangTag); } /** diff --git a/browser/components/translations/content/selectTranslationsPanel.inc.xhtml b/browser/components/translations/content/selectTranslationsPanel.inc.xhtml index 72e2bd7095..8c643ea3f6 100644 --- a/browser/components/translations/content/selectTranslationsPanel.inc.xhtml +++ b/browser/components/translations/content/selectTranslationsPanel.inc.xhtml @@ -4,99 +4,99 @@ <html:template id="template-select-translations-panel"> <panel id="select-translations-panel" - class="panel-no-padding translations-panel" + class="panel-no-padding translations-panel translations-panel-view" type="arrow" role="alertdialog" noautofocus="true" aria-labelledby="translations-panel-header" - orient="vertical"> - <panelmultiview id="select-translations-panel-multiview" mainViewId="select-translations-panel-view-default"> - <panelview id="select-translations-panel-view-default" - class="PanelUI-subView translations-panel-view" - role="document" - mainview-with-header="true" - has-custom-header="true"> - <hbox class="panel-header select-translations-panel-header"> - <html:h1 class="translations-panel-header-wrapper"> - <html:span id="select-translations-panel-header" data-l10n-id="select-translations-panel-header"> - </html:span> - </html:h1> - <hbox class="translations-panel-beta"> - <image id="select-translations-panel-beta-icon" - class="translations-panel-beta-icon"> - </image> - </hbox> - <toolbarbutton id="select-translations-panel-settings" - class="panel-info-button translations-panel-settings-gear-icon" - data-l10n-id="translations-panel-settings-button" - closemenu="none" /> - </hbox> - <vbox class="select-translations-panel-content"> - <hbox id="select-translations-panel-lang-selection"> - <vbox flex="1"> - <label id="select-translations-panel-from-label" - class="select-translations-panel-label" - data-l10n-id="select-translations-panel-from-label"> - </label> - <menulist id="select-translations-panel-from" - flex="1" - value="detect" - size="large" - data-l10n-id="translations-panel-choose-language" - aria-labelledby="translations-panel-from-label"> - <menupopup id="select-translations-panel-from-menupopup" - class="translations-panel-language-menupopup-from"> - <!-- The list of <menuitem> will be dynamically inserted. --> - </menupopup> - </menulist> - </vbox> - <vbox flex="1"> - <label id="select-translations-panel-to-label" - class="select-translations-panel-label" - data-l10n-id="select-translations-panel-to-label"> - </label> - <menulist id="select-translations-panel-to" - flex="1" - value="detect" - size="large" - data-l10n-id="translations-panel-choose-language" - aria-labelledby="translations-panel-to-label"> - <menupopup id="select-translations-panel-to-menupopup" - class="translations-panel-language-menupopup-to"> - <!-- The list of <menuitem> will be dynamically inserted. --> - </menupopup> - </menulist> - </vbox> - </hbox> + orient="vertical" + onpopupshown="SelectTranslationsPanel.handlePanelPopupShownEvent(event)" + onpopuphidden="SelectTranslationsPanel.handlePanelPopupHiddenEvent(event)"> + <hbox class="panel-header select-translations-panel-header"> + <html:h1 class="translations-panel-header-wrapper"> + <html:span id="select-translations-panel-header" data-l10n-id="select-translations-panel-header"> + </html:span> + </html:h1> + <hbox class="translations-panel-beta"> + <image id="select-translations-panel-beta-icon" + class="translations-panel-beta-icon"> + </image> + </hbox> + <toolbarbutton id="select-translations-panel-settings" + class="panel-info-button translations-panel-settings-gear-icon" + data-l10n-id="translations-panel-settings-button" + closemenu="none" /> + </hbox> + <vbox class="select-translations-panel-content"> + <hbox id="select-translations-panel-lang-selection"> + <vbox flex="1"> + <label id="select-translations-panel-from-label" + class="select-translations-panel-label" + data-l10n-id="select-translations-panel-from-label"> + </label> + <menulist id="select-translations-panel-from" + flex="1" + value="" + size="large" + data-l10n-id="translations-panel-choose-language" + aria-labelledby="select-translations-panel-from-label" + noinitialselection="true" + oncommand="SelectTranslationsPanel.onChangeFromLanguage(event)"> + <menupopup id="select-translations-panel-from-menupopup" + onpopupshown="SelectTranslationsPanel.handlePanelPopupShownEvent(event)" + class="translations-panel-language-menupopup-from"> + <!-- The list of <menuitem> will be dynamically inserted. --> + </menupopup> + </menulist> </vbox> - <vbox class="select-translations-panel-content"> - <html:textarea id="select-translations-panel-translation-area" - data-l10n-id="select-translations-panel-placeholder-text" - readonly="true" - tabindex="0"> - </html:textarea> + <vbox flex="1"> + <label id="select-translations-panel-to-label" + class="select-translations-panel-label" + data-l10n-id="select-translations-panel-to-label"> + </label> + <menulist id="select-translations-panel-to" + flex="1" + value="" + size="large" + data-l10n-id="translations-panel-choose-language" + aria-labelledby="select-translations-panel-to-label" + noinitialselection="true" + oncommand="SelectTranslationsPanel.onChangeToLanguage(event)"> + <menupopup id="select-translations-panel-to-menupopup" + onpopupshown="SelectTranslationsPanel.handlePanelPopupShownEvent(event)" + class="translations-panel-language-menupopup-to"> + <!-- The list of <menuitem> will be dynamically inserted. --> + </menupopup> + </menulist> </vbox> + </hbox> + </vbox> + <vbox class="select-translations-panel-content"> + <html:textarea id="select-translations-panel-text-area" + class="select-translations-panel-text-area" + readonly="true" + tabindex="0"> + </html:textarea> + </vbox> - <hbox class="select-translations-panel-content"> - <button id="select-translations-panel-copy-button" - class="footer-button select-translations-panel-button select-translations-panel-copy-button" - data-l10n-id="select-translations-panel-copy-button"> - </button> - </hbox> + <hbox class="select-translations-panel-content"> + <button id="select-translations-panel-copy-button" + class="footer-button select-translations-panel-button select-translations-panel-copy-button" + data-l10n-id="select-translations-panel-copy-button"> + </button> + </hbox> - <html:moz-button-group class="panel-footer translations-panel-footer"> - <button id="select-translations-panel-translate-full-page-button" - class="footer-button select-translations-panel-button" - data-l10n-id="select-translations-panel-translate-full-page-button"> - </button> - <button id="select-translations-panel-done-button" - class="footer-button select-translations-panel-button" - data-l10n-id="select-translations-panel-done-button" - default="true" - oncommand = "SelectTranslationsPanel.close()"> - </button> - </html:moz-button-group> - </panelview> - </panelmultiview> + <html:moz-button-group class="panel-footer translations-panel-footer"> + <button id="select-translations-panel-translate-full-page-button" + class="footer-button select-translations-panel-button" + data-l10n-id="select-translations-panel-translate-full-page-button"> + </button> + <button id="select-translations-panel-done-button" + class="footer-button select-translations-panel-button" + data-l10n-id="select-translations-panel-done-button" + default="true" + oncommand = "SelectTranslationsPanel.close()"> + </button> + </html:moz-button-group> </panel> </html:template> diff --git a/browser/components/translations/content/selectTranslationsPanel.js b/browser/components/translations/content/selectTranslationsPanel.js index b4fe3e9735..bb825eaefa 100644 --- a/browser/components/translations/content/selectTranslationsPanel.js +++ b/browser/components/translations/content/selectTranslationsPanel.js @@ -4,15 +4,27 @@ /* eslint-env mozilla/browser-window */ +/** + * @typedef {import("../../../../toolkit/components/translations/translations").SelectTranslationsPanelState} SelectTranslationsPanelState + */ + ChromeUtils.defineESModuleGetters(this, { LanguageDetector: "resource://gre/modules/translation/LanguageDetector.sys.mjs", TranslationsPanelShared: "chrome://browser/content/translations/TranslationsPanelShared.sys.mjs", + Translator: "chrome://global/content/translations/Translator.mjs", }); /** - * This singleton class controls the Translations popup panel. + * This singleton class controls the SelectTranslations panel. + * + * A global instance of this class is created once per top ChromeWindow and is initialized + * when the context menu is opened in that window. + * + * See the comment above TranslationsParent for more details. + * + * @see TranslationsParent */ var SelectTranslationsPanel = new (class { /** @type {Console?} */ @@ -40,6 +52,69 @@ var SelectTranslationsPanel = new (class { } /** + * The textarea height for shorter text. + * + * @type {string} + */ + #shortTextHeight = "8em"; + + /** + * Retrieves the read-only textarea height for shorter text. + * + * @see #shortTextHeight + */ + get shortTextHeight() { + return this.#shortTextHeight; + } + + /** + * The textarea height for shorter text. + * + * @type {string} + */ + #longTextHeight = "16em"; + + /** + * Retrieves the read-only textarea height for longer text. + * + * @see #longTextHeight + */ + get longTextHeight() { + return this.#longTextHeight; + } + + /** + * The threshold used to determine when the panel should + * use the short text-height vs. the long-text height. + * + * @type {number} + */ + #textLengthThreshold = 800; + + /** + * Retrieves the read-only text-length threshold. + * + * @see #textLengthThreshold + */ + get textLengthThreshold() { + return this.#textLengthThreshold; + } + + /** + * The localized placeholder text to display when idle. + * + * @type {string} + */ + #idlePlaceholderText; + + /** + * The localized placeholder text to display when translating. + * + * @type {string} + */ + #translatingPlaceholderText; + + /** * Where the lazy elements are stored. * * @type {Record<string, Element>?} @@ -47,6 +122,29 @@ var SelectTranslationsPanel = new (class { #lazyElements; /** + * The internal state of the SelectTranslationsPanel. + * + * @type {SelectTranslationsPanelState} + */ + #translationState = { phase: "closed" }; + + /** + * The Translator for the current language pair. + * + * @type {Translator} + */ + #translator; + + /** + * An Id that increments with each translation, used to help keep track + * of whether an active translation request continue its progression or + * stop due to the existence of a newer translation request. + * + * @type {number} + */ + #translationId = 0; + + /** * Lazily creates the dom elements, and lazily selects them. * * @returns {Record<string, Element>} @@ -77,11 +175,12 @@ var SelectTranslationsPanel = new (class { doneButton: "select-translations-panel-done-button", fromLabel: "select-translations-panel-from-label", fromMenuList: "select-translations-panel-from", + fromMenuPopup: "select-translations-panel-from-menupopup", header: "select-translations-panel-header", - multiview: "select-translations-panel-multiview", - textArea: "select-translations-panel-translation-area", + textArea: "select-translations-panel-text-area", toLabel: "select-translations-panel-to-label", toMenuList: "select-translations-panel-to", + toMenuPopup: "select-translations-panel-to-menupopup", translateFullPageButton: "select-translations-panel-translate-full-page-button", }); @@ -91,6 +190,43 @@ var SelectTranslationsPanel = new (class { } /** + * Attempts to determine the best language tag to use as the source language for translation. + * If the detected language is not supported, attempts to fallback to the document's language tag. + * + * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed. + * + * @returns {Promise<string>} - The code of a supported language, a supported document language, or the top detected language. + */ + async getTopSupportedDetectedLanguage(textToTranslate) { + // First see if any of the detected languages are supported and return it if so. + const { language, languages } = await LanguageDetector.detectLanguage( + textToTranslate + ); + for (const { languageCode } of languages) { + const isSupported = await TranslationsParent.isSupportedAsFromLang( + languageCode + ); + if (isSupported) { + return languageCode; + } + } + + // Since none of the detected languages were supported, check to see if the + // document has a specified language tag that is supported. + const actor = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ); + const detectedLanguages = actor.languageState.detectedLanguages; + if (detectedLanguages?.isDocLangTagSupported) { + return detectedLanguages.docLangTag; + } + + // No supported language was found, so return the top detected language + // to inform the panel's unsupported language state. + return language; + } + + /** * Detects the language of the provided text and retrieves a language pair for translation * based on user settings. * @@ -101,9 +237,7 @@ var SelectTranslationsPanel = new (class { */ async getLangPairPromise(textToTranslate) { const [fromLang, toLang] = await Promise.all([ - LanguageDetector.detectLanguage(textToTranslate).then( - ({ language }) => language - ), + SelectTranslationsPanel.getTopSupportedDetectedLanguage(textToTranslate), TranslationsParent.getTopPreferredSupportedToLang(), ]); @@ -122,99 +256,740 @@ var SelectTranslationsPanel = new (class { } /** - * Builds the <menulist> of languages for both the "from" and "to". This can be - * called every time the popup is shown, as it will retry when there is an error - * (such as a network error) or be a noop if it's already initialized. + * Ensures that the from-language and to-language dropdowns are built. + * + * This can be called every time the popup is shown, since it will retry + * when there is an error (such as a network error) or be a no-op if the + * dropdowns have already been initialized. */ async #ensureLangListsBuilt() { - try { - await TranslationsPanelShared.ensureLangListsBuilt( - document, - this.elements.panel - ); - } catch (error) { - this.console?.error(error); - } + await TranslationsPanelShared.ensureLangListsBuilt(document, this); } /** - * Updates the language dropdown based on the provided language tag. + * Initializes the selected value of the given language dropdown based on the language tag. * * @param {string} langTag - A BCP-47 language tag. - * @param {Element} menuList - The dropdown menu element that will be updated based on language support. + * @param {Element} menuList - The menu list element to update. + * * @returns {Promise<void>} */ - async #updateLanguageDropdown(langTag, menuList) { - const langTagIsSupported = + async #initializeLanguageMenuList(langTag, menuList) { + const isLangTagSupported = menuList.id === this.elements.fromMenuList.id ? await TranslationsParent.isSupportedAsFromLang(langTag) : await TranslationsParent.isSupportedAsToLang(langTag); - if (langTagIsSupported) { + if (isLangTagSupported) { // Remove the data-l10n-id because the menulist label will // be populated from the supported language's display name. - menuList.value = langTag; menuList.removeAttribute("data-l10n-id"); + menuList.value = langTag; } else { - // Set the data-l10n-id placeholder because no valid - // language will be selected when the panel opens. - menuList.value = undefined; - document.l10n.setAttributes( - menuList, - "translations-panel-choose-language" - ); - await document.l10n.translateElements([menuList]); + await this.#deselectLanguage(menuList); } } /** - * Updates the language selection dropdowns based on the given langPairPromise. + * Initializes the selected values of the from-language and to-language menu + * lists based on the result of the given language pair promise. * * @param {Promise<{fromLang?: string, toLang?: string}>} langPairPromise + * * @returns {Promise<void>} */ - async #updateLanguageDropdowns(langPairPromise) { + async #initializeLanguageMenuLists(langPairPromise) { const { fromLang, toLang } = await langPairPromise; - - this.console?.debug(`fromLang(${fromLang})`); - this.console?.debug(`toLang(${toLang})`); - const { fromMenuList, toMenuList } = this.elements; - await Promise.all([ - this.#updateLanguageDropdown(fromLang, fromMenuList), - this.#updateLanguageDropdown(toLang, toMenuList), + this.#initializeLanguageMenuList(fromLang, fromMenuList), + this.#initializeLanguageMenuList(toLang, toMenuList), ]); } /** - * Opens the panel and populates the currently selected fromLang and toLang based - * on the result of the langPairPromise. + * Opens the panel, ensuring the panel's UI and state are initialized correctly. * * @param {Event} event - The triggering event for opening the panel. + * @param {number} screenX - The x-axis location of the screen at which to open the popup. + * @param {number} screenY - The y-axis location of the screen at which to open the popup. + * @param {string} sourceText - The text to translate. * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns. + * * @returns {Promise<void>} */ - async open(event, langPairPromise) { - this.console?.log("Showing a translation panel."); + async open(event, screenX, screenY, sourceText, langPairPromise) { + if (this.#isOpen()) { + return; + } + this.#registerSourceText(sourceText); await this.#ensureLangListsBuilt(); - await this.#updateLanguageDropdowns(langPairPromise); - - // TODO(Bug 1878721) Rework the logic of where to open the panel. - // - // For the moment, the Select Translations panel opens at the - // AppMenu Button, but it will eventually need to open near - // to the selected content. - const appMenuButton = document.getElementById("PanelUI-menu-button"); - const { panel, textArea } = this.elements; - - panel.addEventListener("popupshown", () => textArea.focus(), { - once: true, + + await Promise.all([ + this.#cachePlaceholderText(), + this.#initializeLanguageMenuLists(langPairPromise), + ]); + + this.#displayIdlePlaceholder(); + this.#maybeRequestTranslation(); + await this.#openPopup(event, screenX, screenY); + } + + /** + * Opens a the panel popup at a location on the screen. + * + * @param {Event} event - The event that triggers the popup opening. + * @param {number} screenX - The x-axis location of the screen at which to open the popup. + * @param {number} screenY - The y-axis location of the screen at which to open the popup. + */ + async #openPopup(event, screenX, screenY) { + await window.ensureCustomElements("moz-button-group"); + + this.console?.log("Showing SelectTranslationsPanel"); + const { panel } = this.elements; + panel.openPopupAtScreen(screenX, screenY, /* isContextMenu */ false, event); + } + + /** + * Adds the source text to the translation state and adapts the size of the text area based + * on the length of the text. + * + * @param {string} sourceText - The text to translate. + * + * @returns {Promise<void>} + */ + #registerSourceText(sourceText) { + const { textArea } = this.elements; + this.#changeStateTo("idle", /* retainEntries */ false, { + sourceText, }); - await PanelMultiView.openPopup(panel, appMenuButton, { - position: "bottomright topright", - triggerEvent: event, - }).catch(error => this.console?.error(error)); + + if (sourceText.length < SelectTranslationsPanel.textLengthThreshold) { + textArea.style.height = SelectTranslationsPanel.shortTextHeight; + } else { + textArea.style.height = SelectTranslationsPanel.longTextHeight; + } + } + + /** + * Caches the localized text to use as placeholders. + */ + async #cachePlaceholderText() { + const [idleText, translatingText] = await document.l10n.formatValues([ + { id: "select-translations-panel-idle-placeholder-text" }, + { id: "select-translations-panel-translating-placeholder-text" }, + ]); + this.#idlePlaceholderText = idleText; + this.#translatingPlaceholderText = translatingText; + } + + /** + * Handles events when a popup is shown within the panel, including showing + * the panel itself. + * + * @param {Event} event - The event that triggered the popup to show. + */ + handlePanelPopupShownEvent(event) { + const { panel, fromMenuPopup, toMenuPopup } = this.elements; + switch (event.target.id) { + case panel.id: { + this.#updatePanelUIFromState(); + break; + } + case fromMenuPopup.id: { + this.#maybeTranslateOnEvents(["popuphidden"], fromMenuPopup); + break; + } + case toMenuPopup.id: { + this.#maybeTranslateOnEvents(["popuphidden"], toMenuPopup); + break; + } + } + } + + /** + * Handles events when a popup is closed within the panel, including closing + * the panel itself. + * + * @param {Event} event - The event that triggered the popup to close. + */ + handlePanelPopupHiddenEvent(event) { + const { panel } = this.elements; + switch (event.target.id) { + case panel.id: { + this.#changeStateToClosed(); + break; + } + } + } + + /** + * Handles events when the panels select from-language is changed. + */ + onChangeFromLanguage() { + const { fromMenuList, toMenuList } = this.elements; + this.#maybeTranslateOnEvents(["blur", "keypress"], fromMenuList); + this.#maybeStealLanguageFrom(toMenuList); + } + + /** + * Handles events when the panels select to-language is changed. + */ + onChangeToLanguage() { + const { toMenuList, fromMenuList } = this.elements; + this.#maybeTranslateOnEvents(["blur", "keypress"], toMenuList); + this.#maybeStealLanguageFrom(fromMenuList); + } + + /** + * Clears the selected language and ensures that the menu list displays + * the proper placeholder text. + * + * @param {Element} menuList - The target menu list element to update. + */ + async #deselectLanguage(menuList) { + menuList.value = ""; + document.l10n.setAttributes(menuList, "translations-panel-choose-language"); + await document.l10n.translateElements([menuList]); + } + + /** + * Deselects the language from the target menu list if both menu lists + * have the same language selected, simulating the effect of one menu + * list stealing the selected language value from the other. + * + * @param {Element} menuList - The target menu list element to update. + */ + async #maybeStealLanguageFrom(menuList) { + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + if (fromLanguage === toLanguage) { + await this.#deselectLanguage(menuList); + this.#maybeFocusMenuList(menuList); + } + } + + /** + * Focuses on the given menu list if provided and empty, or defaults to focusing one + * of the from-menu or to-menu lists if either is empty. + * + * @param {Element} [menuList] - The menu list to focus if specified. + */ + #maybeFocusMenuList(menuList) { + if (menuList && !menuList.value) { + menuList.focus({ focusVisible: true }); + return; + } + + const { fromMenuList, toMenuList } = this.elements; + if (!fromMenuList.value) { + fromMenuList.focus({ focusVisible: true }); + } else if (!toMenuList.value) { + toMenuList.focus({ focusVisible: true }); + } + } + + /** + * Focuses the translated-text area and sets its overflow to auto post-animation. + */ + #indicateTranslatedTextArea({ overflow }) { + const { textArea } = this.elements; + textArea.focus({ focusVisible: true }); + requestAnimationFrame(() => { + // We want to set overflow to auto as the final animation, because if it is + // set before the translated text is displayed, then the scrollTop will + // move to the bottom as the text is populated. + // + // Setting scrollTop = 0 on its own works, but it sometimes causes an animation + // of the text jumping from the bottom to the top. It looks a lot cleaner to + // disable overflow before rendering the text, then re-enable it after it renders. + requestAnimationFrame(() => { + textArea.style.overflow = overflow; + textArea.scrollTop = 0; + }); + }); + } + + /** + * Checks if the given language pair matches the panel's currently selected language pair. + * + * @param {string} fromLanguage - The from-language to compare. + * @param {string} toLanguage - The to-language to compare. + * + * @returns {boolean} - True if the given language pair matches the selected languages in the panel UI, otherwise false. + */ + #isSelectedLangPair(fromLanguage, toLanguage) { + const { fromLanguage: selectedFromLang, toLanguage: selectedToLang } = + this.#getSelectedLanguagePair(); + return fromLanguage === selectedFromLang && toLanguage === selectedToLang; + } + + /** + * Checks if the translator's language configuration matches the given language pair. + * + * @param {string} fromLanguage - The from-language to compare. + * @param {string} toLanguage - The to-language to compare. + * + * @returns {boolean} - True if the translator's languages match the given pair, otherwise false. + */ + #translatorMatchesLangPair(fromLanguage, toLanguage) { + return ( + this.#translator?.fromLanguage === fromLanguage && + this.#translator?.toLanguage === toLanguage + ); + } + + /** + * Retrieves the currently selected language pair from the menu lists. + * + * @returns {{fromLanguage: string, toLanguage: string}} An object containing the selected languages. + */ + #getSelectedLanguagePair() { + const { fromMenuList, toMenuList } = this.elements; + return { + fromLanguage: fromMenuList.value, + toLanguage: toMenuList.value, + }; + } + + /** + * Retrieves the source text from the translation state. + * This value is not available when the panel is closed. + * + * @returns {string | undefined} The source text. + */ + getSourceText() { + return this.#translationState?.sourceText; + } + + /** + * Retrieves the source text from the translation state. + * This value is only available in the translated phase. + * + * @returns {string | undefined} The translated text. + */ + getTranslatedText() { + return this.#translationState?.translatedText; + } + + /** + * Retrieves the current phase of the translation state. + * + * @returns {SelectTranslationsPanelState} + */ + #phase() { + return this.#translationState.phase; + } + + /** + * @returns {boolean} True if the panel is open, otherwise false. + */ + #isOpen() { + return this.#phase() !== "closed"; + } + + /** + * @returns {boolean} True if the panel is closed, otherwise false. + */ + #isClosed() { + return this.#phase() === "closed"; + } + + /** + * Changes the translation state to a new phase with options to retain or overwrite existing entries. + * + * @param {SelectTranslationsPanelState} phase - The new phase to transition to. + * @param {boolean} [retainEntries] - Whether to retain existing state entries that are not overwritten. + * @param {object | null} [data=null] - Additional data to merge into the state. + * @throws {Error} If an invalid phase is specified. + */ + #changeStateTo(phase, retainEntries, data = null) { + const { textArea } = this.elements; + switch (phase) { + case "translating": { + textArea.classList.add("translating"); + break; + } + case "closed": + case "idle": + case "translatable": + case "translated": { + textArea.classList.remove("translating"); + break; + } + default: { + throw new Error(`Invalid state change to '${phase}'`); + } + } + + const previousPhase = this.#phase(); + if (data && retainEntries) { + // Change the phase and apply new entries from data, but retain non-overwritten entries from previous state. + this.#translationState = { ...this.#translationState, phase, ...data }; + } else if (data) { + // Change the phase and apply new entries from data, but drop any entries that are not overwritten by data. + this.#translationState = { phase, ...data }; + } else if (retainEntries) { + // Change only the phase and retain all entries from previous data. + this.#translationState.phase = phase; + } else { + // Change the phase and delete all entries from previous data. + this.#translationState = { phase }; + } + + if (previousPhase === this.#phase()) { + // Do not continue on to update the UI because the phase didn't change. + return; + } + + const { fromLanguage, toLanguage } = this.#translationState; + this.console?.debug( + `SelectTranslationsPanel (${fromLanguage ? fromLanguage : "??"}-${ + toLanguage ? toLanguage : "??" + }) state change (${previousPhase} => ${phase})` + ); + + this.#updatePanelUIFromState(); + } + + /** + * Changes the phase to closed, discarding any entries in the translation state. + */ + #changeStateToClosed() { + this.#changeStateTo("closed", /* retainEntries */ false); + } + + /** + * Changes the phase from "translatable" to "translating". + * + * @throws {Error} If the current state is not "translatable". + */ + #changeStateToTranslating() { + const phase = this.#phase(); + if (phase !== "translatable") { + throw new Error(`Invalid state change (${phase} => translating)`); + } + this.#changeStateTo("translating", /* retainEntries */ true); + } + + /** + * Changes the phase from "translating" to "translated". + * + * @throws {Error} If the current state is not "translating". + */ + #changeStateToTranslated(translatedText) { + const phase = this.#phase(); + if (phase !== "translating") { + throw new Error(`Invalid state change (${phase} => translated)`); + } + this.#changeStateTo("translated", /* retainEntries */ true, { + translatedText, + }); + } + + /** + * Transitions the phase of the state based on the given language pair. + * + * @param {string} fromLanguage - The BCP-47 from-language tag. + * @param {string} toLanguage - The BCP-47 to-language tag. + * + * @returns {SelectTranslationsPanelState} The new phase of the translation state. + */ + #changeStateByLanguagePair(fromLanguage, toLanguage) { + const { + phase: previousPhase, + fromLanguage: previousFromLanguage, + toLanguage: previousToLanguage, + } = this.#translationState; + + let nextPhase = "translatable"; + + if ( + // No from-language is selected, so we cannot translate. + !fromLanguage || + // No to-language is selected, so we cannot translate. + !toLanguage || + // The same language has been selected, so we cannot translate. + fromLanguage === toLanguage + ) { + nextPhase = "idle"; + } else if ( + // The languages have not changed, so there is nothing to do. + previousFromLanguage === fromLanguage && + previousToLanguage === toLanguage + ) { + nextPhase = previousPhase; + } + + this.#changeStateTo(nextPhase, /* retainEntries */ true, { + fromLanguage, + toLanguage, + }); + + return nextPhase; + } + + /** + * Determines whether translation should continue based on panel state and language pair. + * + * @param {number} translationId - The id of the translation request to match. + * @param {string} fromLanguage - The from-language to analyze. + * @param {string} toLanguage - The to-language to analyze. + * + * @returns {boolean} True if translation should continue with the given pair, otherwise false. + */ + #shouldContinueTranslation(translationId, fromLanguage, toLanguage) { + return ( + // Continue only if the panel is still open. + this.#isOpen() && + // Continue only if the current translationId matches. + translationId === this.#translationId && + // Continue only if the given language pair is still the actively selected pair. + this.#isSelectedLangPair(fromLanguage, toLanguage) && + // Continue only if the given language pair matches the current translator. + this.#translatorMatchesLangPair(fromLanguage, toLanguage) + ); + } + + /** + * Displays the placeholder text for the translation state's "idle" phase. + */ + #displayIdlePlaceholder() { + const { textArea } = SelectTranslationsPanel.elements; + textArea.value = this.#idlePlaceholderText; + this.#updateTextDirection(); + this.#updateConditionalUIEnabledState(); + this.#maybeFocusMenuList(); + } + + /** + * Displays the placeholder text for the translation state's "translating" phase. + */ + #displayTranslatingPlaceholder() { + const { textArea } = SelectTranslationsPanel.elements; + textArea.value = this.#translatingPlaceholderText; + this.#updateTextDirection(); + this.#updateConditionalUIEnabledState(); + this.#indicateTranslatedTextArea({ overflow: "hidden" }); + } + + /** + * Displays the translated text for the translation state's "translated" phase. + */ + #displayTranslatedText() { + const { toLanguage } = this.#getSelectedLanguagePair(); + const { textArea } = SelectTranslationsPanel.elements; + textArea.value = this.getTranslatedText(); + this.#updateTextDirection(toLanguage); + this.#updateConditionalUIEnabledState(); + this.#indicateTranslatedTextArea({ overflow: "auto" }); + } + + /** + * Enables or disables UI components that are conditional on a valid language pair being selected. + */ + #updateConditionalUIEnabledState() { + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + const { copyButton, translateFullPageButton, textArea } = this.elements; + + const invalidLangPairSelected = !fromLanguage || !toLanguage; + const isTranslating = this.#phase() === "translating"; + + textArea.disabled = invalidLangPairSelected; + translateFullPageButton.disabled = invalidLangPairSelected; + copyButton.disabled = invalidLangPairSelected || isTranslating; + } + + /** + * Updates the panel UI based on the current phase of the translation state. + */ + #updatePanelUIFromState() { + switch (this.#phase()) { + case "idle": { + this.#displayIdlePlaceholder(); + break; + } + case "translating": { + this.#displayTranslatingPlaceholder(); + break; + } + case "translated": { + this.#displayTranslatedText(); + break; + } + } + } + + /** + * Sets the text direction attribute in the text areas based on the specified language. + * Uses the given language tag if provided, otherwise uses the current app locale. + * + * @param {string} [langTag] - The language tag to determine text direction. + */ + #updateTextDirection(langTag) { + const { textArea } = this.elements; + if (langTag) { + const scriptDirection = Services.intl.getScriptDirection(langTag); + textArea.setAttribute("dir", scriptDirection); + } else { + textArea.removeAttribute("dir"); + } + } + + /** + * Requests a translations port for a given language pair. + * + * @param {string} fromLanguage - The from-language. + * @param {string} toLanguage - The to-language. + * + * @returns {Promise<MessagePort | undefined>} The message port promise. + */ + async #requestTranslationsPort(fromLanguage, toLanguage) { + const innerWindowId = + gBrowser.selectedBrowser.browsingContext.top.embedderElement + .innerWindowID; + if (!innerWindowId) { + return undefined; + } + const port = await TranslationsParent.requestTranslationsPort( + innerWindowId, + fromLanguage, + toLanguage + ); + return port; + } + + /** + * Retrieves the existing translator for the specified language pair if it matches, + * otherwise creates a new translator. + * + * @param {string} fromLanguage - The source language code. + * @param {string} toLanguage - The target language code. + * + * @returns {Promise<Translator>} A promise that resolves to a `Translator` instance for the given language pair. + */ + async #getOrCreateTranslator(fromLanguage, toLanguage) { + if (this.#translatorMatchesLangPair(fromLanguage, toLanguage)) { + return this.#translator; + } + + this.console?.log( + `Creating new Translator (${fromLanguage}-${toLanguage})` + ); + if (this.#translator) { + this.#translator.destroy(); + this.#translator = null; + } + + this.#translator = await Translator.create( + fromLanguage, + toLanguage, + this.#requestTranslationsPort + ); + return this.#translator; + } + + /** + * Initiates the translation process if the panel state and selected languages + * meet the conditions for translation. + */ + #maybeRequestTranslation() { + if (this.#isClosed()) { + return; + } + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + const nextState = this.#changeStateByLanguagePair(fromLanguage, toLanguage); + if (nextState !== "translatable") { + return; + } + + const translationId = ++this.#translationId; + this.#getOrCreateTranslator(fromLanguage, toLanguage) + .then(translator => { + if ( + this.#shouldContinueTranslation( + translationId, + fromLanguage, + toLanguage + ) + ) { + this.#changeStateToTranslating(); + return translator.translate(this.getSourceText()); + } + return null; + }) + .then(translatedText => { + if ( + translatedText && + this.#shouldContinueTranslation( + translationId, + fromLanguage, + toLanguage + ) + ) { + this.#changeStateToTranslated(translatedText); + } else if (this.#isOpen()) { + this.#changeStateTo("idle", /* retainEntires */ false, { + sourceText: this.getSourceText(), + }); + } + }) + .catch(error => this.console?.error(error)); + } + + /** + * Attaches event listeners to the target element for initiating translation on specified event types. + * + * @param {string[]} eventTypes - An array of event types to listen for. + * @param {object} target - The target element to attach event listeners to. + * @throws {Error} If an unrecognized event type is provided. + */ + #maybeTranslateOnEvents(eventTypes, target) { + if (!target.translationListenerCallbacks) { + target.translationListenerCallbacks = []; + } + if (target.translationListenerCallbacks.length === 0) { + for (const eventType of eventTypes) { + let callback; + switch (eventType) { + case "blur": + case "popuphidden": { + callback = () => { + this.#maybeRequestTranslation(); + this.#removeTranslationListeners(target); + }; + break; + } + case "keypress": { + callback = event => { + if (event.key === "Enter") { + this.#maybeRequestTranslation(); + } + this.#removeTranslationListeners(target); + }; + break; + } + default: { + throw new Error( + `Invalid translation event type given: '${eventType}` + ); + } + } + target.addEventListener(eventType, callback, { once: true }); + target.translationListenerCallbacks.push({ eventType, callback }); + } + } + } + + /** + * Removes all translation event listeners from the target element. + * + * @param {Element} target - The element from which event listeners are to be removed. + */ + #removeTranslationListeners(target) { + for (const { eventType, callback } of target.translationListenerCallbacks) { + target.removeEventListener(eventType, callback); + } + target.translationListenerCallbacks = []; } })(); diff --git a/browser/components/translations/moz.build b/browser/components/translations/moz.build index 212b93e509..49f3afc632 100644 --- a/browser/components/translations/moz.build +++ b/browser/components/translations/moz.build @@ -3,7 +3,7 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. with Files("**"): - BUG_COMPONENT = ("Firefox", "Translation") + BUG_COMPONENT = ("Firefox", "Translations") BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] diff --git a/browser/components/translations/tests/browser/browser.toml b/browser/components/translations/tests/browser/browser.toml index a9d36363da..472ae28866 100644 --- a/browser/components/translations/tests/browser/browser.toml +++ b/browser/components/translations/tests/browser/browser.toml @@ -15,6 +15,10 @@ support-files = [ ["browser_translations_about_preferences_settings_ui.js"] +["browser_translations_full_page_move_tab_to_new_window.js"] + +["browser_translations_full_page_multiple_windows.js"] + ["browser_translations_full_page_panel_a11y_focus.js"] ["browser_translations_full_page_panel_always_translate_language_bad_data.js"] @@ -51,8 +55,6 @@ support-files = [ ["browser_translations_full_page_panel_engine_unsupported.js"] -["browser_translations_full_page_panel_engine_unsupported_lang.js"] - ["browser_translations_full_page_panel_firstrun.js"] ["browser_translations_full_page_panel_firstrun_revisit.js"] @@ -62,6 +64,8 @@ skip-if = ["true"] ["browser_translations_full_page_panel_gear.js"] +["browser_translations_full_page_panel_init_failure.js"] + ["browser_translations_full_page_panel_never_translate_language.js"] ["browser_translations_full_page_panel_never_translate_site_auto.js"] @@ -77,6 +81,8 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227 ["browser_translations_full_page_panel_switch_languages.js"] +["browser_translations_full_page_panel_unsupported_lang.js"] + ["browser_translations_full_page_reader_mode.js"] ["browser_translations_full_page_telemetry_firstrun_auto_translate.js"] @@ -109,6 +115,30 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227 ["browser_translations_select_context_menu_with_text_selected.js"] -["browser_translations_select_panel_language_selectors.js"] +["browser_translations_select_panel_engine_cache.js"] + +["browser_translations_select_panel_fallback_to_doc_language.js"] + +["browser_translations_select_panel_open_to_idle_state.js"] + +["browser_translations_select_panel_retranslate_on_change_language_directly.js"] + +["browser_translations_select_panel_retranslate_on_change_language_from_dropdown_menu.js"] + +["browser_translations_select_panel_select_current_language_directly.js"] + +["browser_translations_select_panel_select_current_language_from_dropdown_menu.js"] + +["browser_translations_select_panel_select_same_from_and_to_languages_directly.js"] + +["browser_translations_select_panel_select_same_from_and_to_languages_from_dropdown_menu.js"] + +["browser_translations_select_panel_translate_on_change_language_directly.js"] + +["browser_translations_select_panel_translate_on_change_language_from_dropdown_menu.js"] + +["browser_translations_select_panel_translate_on_change_language_multiple_times_directly.js"] + +["browser_translations_select_panel_translate_on_change_language_multiple_times_from_dropdown_menu.js"] -["browser_translations_select_panel_mainview_ui.js"] +["browser_translations_select_panel_translate_on_open.js"] diff --git a/browser/components/translations/tests/browser/browser_translations_about_preferences_settings_ui.js b/browser/components/translations/tests/browser/browser_translations_about_preferences_settings_ui.js index ee81b84a36..f618b27814 100644 --- a/browser/components/translations/tests/browser/browser_translations_about_preferences_settings_ui.js +++ b/browser/components/translations/tests/browser/browser_translations_about_preferences_settings_ui.js @@ -22,8 +22,8 @@ add_task(async function test_translations_settings_pane_elements() { translationsSettingsDescription, translateAlwaysHeader, translateNeverHeader, - translateAlwaysAddButton, - translateNeverAddButton, + translateAlwaysMenuList, + translateNeverMenuList, translateNeverSiteHeader, translateNeverSiteDesc, translateDownloadLanguagesHeader, @@ -41,8 +41,8 @@ add_task(async function test_translations_settings_pane_elements() { translationsSettingsDescription, translateAlwaysHeader, translateNeverHeader, - translateAlwaysAddButton, - translateNeverAddButton, + translateAlwaysMenuList, + translateNeverMenuList, translateNeverSiteHeader, translateNeverSiteDesc, translateDownloadLanguagesHeader, @@ -74,14 +74,203 @@ add_task(async function test_translations_settings_pane_elements() { translationsSettingsDescription, translateAlwaysHeader, translateNeverHeader, - translateAlwaysAddButton, - translateNeverAddButton, + translateAlwaysMenuList, + translateNeverMenuList, translateNeverSiteHeader, translateNeverSiteDesc, translateDownloadLanguagesHeader, translateDownloadLanguagesLearnMore, }, }); + await cleanup(); +}); + +add_task(async function test_translations_settings_always_translate() { + const { + cleanup, + elements: { settingsButton }, + } = await setupAboutPreferences(LANGUAGE_PAIRS, { + prefs: [["browser.translations.newSettingsUI.enable", true]], + }); + + const document = gBrowser.selectedBrowser.contentDocument; + + assertVisibility({ + message: "Expect paneGeneral elements to be visible.", + visible: { settingsButton }, + }); + + const { translateAlwaysMenuList } = + await TranslationsSettingsTestUtils.openAboutPreferencesTranslationsSettingsPane( + settingsButton + ); + let alwaysTranslateSection = document.getElementById( + "translations-settings-always-translate-section" + ); + await testLanguageList(alwaysTranslateSection, translateAlwaysMenuList); + + await cleanup(); +}); + +async function testLanguageList(translateSection, menuList) { + const sectionName = + translateSection.id === "translations-settings-always-translate-section" + ? "Always" + : "Never"; + + is( + translateSection.querySelector(".translations-settings-languages-card"), + null, + `Language list not present in ${sectionName} Translate list` + ); + + for (let i = 0; i < menuList.children[0].children.length; i++) { + menuList.value = menuList.children[0].children[i].value; + + let clickMenu = BrowserTestUtils.waitForEvent(menuList, "command"); + menuList.dispatchEvent(new Event("command")); + await clickMenu; + + /** Languages are always added on the top, so check the firstChild + * for newly added languages. + * the firstChild.lastChild.innerText is the language display name + * which is compared with the menulist display name that is selected + */ + is( + translateSection.querySelector(".translations-settings-language-list") + .firstChild.lastChild.innerText, + getIntlDisplayName(menuList.children[0].children[i].value), + `Language list has element ${getIntlDisplayName( + menuList.children[0].children[i].value + )}` + ); + } + /** The test cases has 4 languages, so check if 4 languages are added to the list */ + let langNum = translateSection.querySelector( + ".translations-settings-language-list" + ).childElementCount; + is(langNum, 4, "Number of languages added is 4"); + + const languagelist = translateSection.querySelector( + ".translations-settings-language-list" + ); + + for (let i = 0; i < langNum; i++) { + // Delete the first language in the list + let langName = languagelist.children[0].lastChild.innerText; + let langButton = languagelist.children[0].querySelector("moz-button"); + + let clickButton = BrowserTestUtils.waitForEvent(langButton, "click"); + langButton.dispatchEvent(new Event("click")); + await clickButton; + + if (i < langNum - 1) { + is( + languagelist.childElementCount, + langNum - i - 1, + `${langName} removed from ${sectionName} Translate` + ); + } else { + /** Check if the language list card is removed after removing the last language */ + is( + translateSection.querySelector(".translations-settings-languages-card"), + null, + `${langName} removed from ${sectionName} Translate` + ); + } + } +} + +add_task(async function test_translations_settings_never_translate() { + const { + cleanup, + elements: { settingsButton }, + } = await setupAboutPreferences(LANGUAGE_PAIRS, { + prefs: [["browser.translations.newSettingsUI.enable", true]], + }); + + const document = gBrowser.selectedBrowser.contentDocument; + + assertVisibility({ + message: "Expect paneGeneral elements to be visible.", + visible: { settingsButton }, + }); + + const { translateNeverMenuList } = + await TranslationsSettingsTestUtils.openAboutPreferencesTranslationsSettingsPane( + settingsButton + ); + let neverTranslateSection = document.getElementById( + "translations-settings-never-translate-section" + ); + await testLanguageList(neverTranslateSection, translateNeverMenuList); + await cleanup(); +}); + +add_task(async function test_translations_settings_download_languages() { + const { + cleanup, + elements: { settingsButton }, + } = await setupAboutPreferences(LANGUAGE_PAIRS, { + prefs: [["browser.translations.newSettingsUI.enable", true]], + }); + assertVisibility({ + message: "Expect paneGeneral elements to be visible.", + visible: { settingsButton }, + }); + + const { translateDownloadLanguagesList } = + await TranslationsSettingsTestUtils.openAboutPreferencesTranslationsSettingsPane( + settingsButton + ); + + let langList = translateDownloadLanguagesList.querySelector( + ".translations-settings-language-list" + ); + + for (let i = 0; i < langList.children.length; i++) { + is( + langList.children[i] + .querySelector("moz-button") + .classList.contains("translations-settings-download-icon"), + true, + "Download icon is visible" + ); + + let clickButton = BrowserTestUtils.waitForEvent( + langList.children[i].querySelector("moz-button"), + "click" + ); + langList.children[i] + .querySelector("moz-button") + .dispatchEvent(new Event("click")); + await clickButton; + + is( + langList.children[i] + .querySelector("moz-button") + .classList.contains("translations-settings-delete-icon"), + true, + "Delete icon is visible" + ); + + clickButton = BrowserTestUtils.waitForEvent( + langList.children[i].querySelector("moz-button"), + "click" + ); + langList.children[i] + .querySelector("moz-button") + .dispatchEvent(new Event("click")); + await clickButton; + + is( + langList.children[i] + .querySelector("moz-button") + .classList.contains("translations-settings-download-icon"), + true, + "Download icon is visible" + ); + } await cleanup(); }); diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_move_tab_to_new_window.js b/browser/components/translations/tests/browser/browser_translations_full_page_move_tab_to_new_window.js new file mode 100644 index 0000000000..f384fc59c8 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_full_page_move_tab_to_new_window.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests a specific situation described in Bug 1893776 + * where the Translations panels were not initializing correctly after + * dragging a tab to become its own new window after opening the panel + * in the previous window. + */ +add_task(async function test_browser_translations_full_page_multiple_windows() { + const window1 = window; + const testPage = await loadTestPage({ + page: SPANISH_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + }); + + await FullPageTranslationsTestUtils.assertTranslationsButton( + { button: true, circleArrows: false, locale: false, icon: true }, + "The translations button is visible.", + window1 + ); + + info("Opening FullPageTranslationsPanel in window1"); + await FullPageTranslationsTestUtils.openPanel({ + onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, + }); + + await FullPageTranslationsTestUtils.clickCancelButton(); + + info("Moving the tab to a new window of its own"); + const window2 = await window1.gBrowser.replaceTabWithWindow(testPage.tab); + const swapDocShellPromise = BrowserTestUtils.waitForEvent( + testPage.tab.linkedBrowser, + "SwapDocShells" + ); + await swapDocShellPromise; + + await FullPageTranslationsTestUtils.assertTranslationsButton( + { button: true, circleArrows: false, locale: false, icon: true }, + "The translations button is visible.", + window2 + ); + + info("Opening FullPageTranslationsPanel in window2"); + await FullPageTranslationsTestUtils.openPanel({ + win: window2, + }); + + info("Translating the same page in window2"); + await FullPageTranslationsTestUtils.clickTranslateButton({ + win: window2, + downloadHandler: testPage.resolveDownloads, + }); + await FullPageTranslationsTestUtils.assertLangTagIsShownOnTranslationsButton( + "es", + "en", + window2 + ); + + await testPage.cleanup(); + await BrowserTestUtils.closeWindow(window2); +}); diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_multiple_windows.js b/browser/components/translations/tests/browser/browser_translations_full_page_multiple_windows.js new file mode 100644 index 0000000000..9bdee2c406 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_full_page_multiple_windows.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * @param {Window} win + */ +function focusWindow(win) { + const promise = BrowserTestUtils.waitForEvent(win, "focus"); + win.focus(); + return promise; +} + +/** + * Test that the full page translation panel works when multiple windows are used. + */ +add_task(async function test_browser_translations_full_page_multiple_windows() { + const window1 = window; + const testPage1 = await loadTestPage({ + page: SPANISH_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + }); + + const window2 = await BrowserTestUtils.openNewBrowserWindow(); + + const testPage2 = await loadTestPage({ + win: window2, + page: SPANISH_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + }); + + // Focus back to the original window first. This ensures coverage for invalid caching + // logic involving multiple windows. + await focusWindow(window1); + + info("Testing window 1"); + await FullPageTranslationsTestUtils.openPanel({ + onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, + }); + await FullPageTranslationsTestUtils.clickTranslateButton({ + downloadHandler: testPage1.resolveDownloads, + }); + await FullPageTranslationsTestUtils.assertPageIsTranslated( + "es", + "en", + testPage1.runInPage, + "Window 1 gets translated", + window1 + ); + + await focusWindow(window2); + + info("Testing window 2"); + await FullPageTranslationsTestUtils.openPanel({ win: window2 }); + await FullPageTranslationsTestUtils.clickTranslateButton({ win: window2 }); + await FullPageTranslationsTestUtils.assertPageIsTranslated( + "es", + "en", + testPage2.runInPage, + "Window 2 gets translated", + window2 + ); + + await testPage2.cleanup(); + await BrowserTestUtils.closeWindow(window2); + await testPage1.cleanup(); +}); diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_panel_init_failure.js b/browser/components/translations/tests/browser/browser_translations_full_page_panel_init_failure.js new file mode 100644 index 0000000000..34986726b8 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_full_page_panel_init_failure.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies that the proper error message is displayed in + * the FullPageTranslationsPanel if the panel tries to open, but the language + * dropdown menus fail to initialize. + */ +add_task(async function test_full_page_translations_panel_init_failure() { + const { cleanup } = await loadTestPage({ + page: SPANISH_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + }); + + TranslationsPanelShared.simulateLangListError(); + await FullPageTranslationsTestUtils.openPanel({ + onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewInitFailure, + }); + + await FullPageTranslationsTestUtils.clickCancelButton(); + + await cleanup(); +}); diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_panel_retry.js b/browser/components/translations/tests/browser/browser_translations_full_page_panel_retry.js index 74d92381b9..01af5cbd8d 100644 --- a/browser/components/translations/tests/browser/browser_translations_full_page_panel_retry.js +++ b/browser/components/translations/tests/browser/browser_translations_full_page_panel_retry.js @@ -37,7 +37,7 @@ add_task(async function test_translations_panel_retry() { onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewRevisit, }); - FullPageTranslationsTestUtils.switchSelectedToLanguage("fr"); + FullPageTranslationsTestUtils.changeSelectedToLanguage("fr"); await FullPageTranslationsTestUtils.clickTranslateButton({ downloadHandler: resolveDownloads, diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_panel_switch_languages.js b/browser/components/translations/tests/browser/browser_translations_full_page_panel_switch_languages.js index 0c5db67b20..6ab70e634f 100644 --- a/browser/components/translations/tests/browser/browser_translations_full_page_panel_switch_languages.js +++ b/browser/components/translations/tests/browser/browser_translations_full_page_panel_switch_languages.js @@ -30,29 +30,29 @@ add_task(async function test_translations_panel_switch_language() { FullPageTranslationsTestUtils.assertSelectedFromLanguage({ langTag: "es" }); FullPageTranslationsTestUtils.assertSelectedToLanguage({ langTag: "en" }); - FullPageTranslationsTestUtils.switchSelectedFromLanguage("en"); + FullPageTranslationsTestUtils.changeSelectedFromLanguage("en"); ok( translateButton.disabled, "The translate button is disabled when the languages are the same" ); - FullPageTranslationsTestUtils.switchSelectedFromLanguage("es"); + FullPageTranslationsTestUtils.changeSelectedFromLanguage("es"); ok( !translateButton.disabled, "When the languages are different it can be translated" ); - FullPageTranslationsTestUtils.switchSelectedFromLanguage(""); + FullPageTranslationsTestUtils.changeSelectedFromLanguage(""); ok( translateButton.disabled, "The translate button is disabled nothing is selected." ); - FullPageTranslationsTestUtils.switchSelectedFromLanguage("en"); - FullPageTranslationsTestUtils.switchSelectedToLanguage("fr"); + FullPageTranslationsTestUtils.changeSelectedFromLanguage("en"); + FullPageTranslationsTestUtils.changeSelectedToLanguage("fr"); ok(!translateButton.disabled, "The translate button can now be used"); diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_panel_engine_unsupported_lang.js b/browser/components/translations/tests/browser/browser_translations_full_page_panel_unsupported_lang.js index 21f7e8fdb7..59be1e329b 100644 --- a/browser/components/translations/tests/browser/browser_translations_full_page_panel_engine_unsupported_lang.js +++ b/browser/components/translations/tests/browser/browser_translations_full_page_panel_unsupported_lang.js @@ -23,6 +23,9 @@ add_task(async function test_unsupported_lang() { }); await FullPageTranslationsTestUtils.clickChangeSourceLanguageButton(); + FullPageTranslationsTestUtils.assertPanelViewDefault(); + FullPageTranslationsTestUtils.assertSelectedFromLanguage({ langTag: "" }); + FullPageTranslationsTestUtils.assertSelectedToLanguage({ langTag: "en" }); await cleanup(); }); diff --git a/browser/components/translations/tests/browser/browser_translations_full_page_telemetry_switch_languages.js b/browser/components/translations/tests/browser/browser_translations_full_page_telemetry_switch_languages.js index ef13940b3f..41183cc9cf 100644 --- a/browser/components/translations/tests/browser/browser_translations_full_page_telemetry_switch_languages.js +++ b/browser/components/translations/tests/browser/browser_translations_full_page_telemetry_switch_languages.js @@ -24,7 +24,7 @@ add_task(async function test_translations_telemetry_switch_from_language() { }); FullPageTranslationsTestUtils.assertSelectedFromLanguage({ langTag: "es" }); - FullPageTranslationsTestUtils.switchSelectedFromLanguage("en"); + FullPageTranslationsTestUtils.changeSelectedFromLanguage("en"); await TestTranslationsTelemetry.assertEvent(Glean.translationsPanel.open, { expectedEventCount: 1, @@ -45,7 +45,7 @@ add_task(async function test_translations_telemetry_switch_from_language() { } ); - FullPageTranslationsTestUtils.switchSelectedFromLanguage("es"); + FullPageTranslationsTestUtils.changeSelectedFromLanguage("es"); await TestTranslationsTelemetry.assertEvent( Glean.translationsPanel.changeFromLanguage, @@ -56,7 +56,7 @@ add_task(async function test_translations_telemetry_switch_from_language() { } ); - FullPageTranslationsTestUtils.switchSelectedFromLanguage(""); + FullPageTranslationsTestUtils.changeSelectedFromLanguage(""); await TestTranslationsTelemetry.assertEvent( Glean.translationsPanel.changeFromLanguage, @@ -65,7 +65,7 @@ add_task(async function test_translations_telemetry_switch_from_language() { } ); - FullPageTranslationsTestUtils.switchSelectedFromLanguage("en"); + FullPageTranslationsTestUtils.changeSelectedFromLanguage("en"); await TestTranslationsTelemetry.assertEvent( Glean.translationsPanel.changeFromLanguage, @@ -100,7 +100,7 @@ add_task(async function test_translations_telemetry_switch_to_language() { }); FullPageTranslationsTestUtils.assertSelectedToLanguage({ langTag: "en" }); - FullPageTranslationsTestUtils.switchSelectedToLanguage("fr"); + FullPageTranslationsTestUtils.changeSelectedToLanguage("fr"); await TestTranslationsTelemetry.assertEvent(Glean.translationsPanel.open, { expectedEventCount: 1, @@ -121,7 +121,7 @@ add_task(async function test_translations_telemetry_switch_to_language() { } ); - FullPageTranslationsTestUtils.switchSelectedToLanguage("en"); + FullPageTranslationsTestUtils.changeSelectedToLanguage("en"); await TestTranslationsTelemetry.assertEvent( Glean.translationsPanel.changeToLanguage, @@ -132,7 +132,7 @@ add_task(async function test_translations_telemetry_switch_to_language() { } ); - FullPageTranslationsTestUtils.switchSelectedToLanguage(""); + FullPageTranslationsTestUtils.changeSelectedToLanguage(""); await TestTranslationsTelemetry.assertEvent( Glean.translationsPanel.changeToLanguage, @@ -141,7 +141,7 @@ add_task(async function test_translations_telemetry_switch_to_language() { } ); - FullPageTranslationsTestUtils.switchSelectedToLanguage("en"); + FullPageTranslationsTestUtils.changeSelectedToLanguage("en"); await TestTranslationsTelemetry.assertEvent( Glean.translationsPanel.changeToLanguage, diff --git a/browser/components/translations/tests/browser/browser_translations_select_context_menu_feature_disabled.js b/browser/components/translations/tests/browser/browser_translations_select_context_menu_feature_disabled.js index a6b3f71924..c3ed228ecc 100644 --- a/browser/components/translations/tests/browser/browser_translations_select_context_menu_feature_disabled.js +++ b/browser/components/translations/tests/browser/browser_translations_select_context_menu_feature_disabled.js @@ -19,7 +19,7 @@ add_task( async function test_translate_selection_menuitem_is_unavailable_with_feature_disabled_and_no_text_selected() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", false]], }); @@ -34,8 +34,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, - openAtSpanishParagraph: true, + selectSpanishSentence: false, + openAtSpanishSentence: true, expectMenuItemVisible: false, }, "The translate-selection context menu item should be unavailable when the feature is disabled." @@ -54,7 +54,7 @@ add_task( add_task( async function test_translate_selection_menuitem_is_unavailable_with_feature_disabled_and_text_selected() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", false]], }); @@ -69,8 +69,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, + selectSpanishSentence: true, + openAtSpanishSentence: true, expectMenuItemVisible: false, }, "The translate-selection context menu item should be unavailable when the feature is disabled." @@ -89,7 +89,7 @@ add_task( add_task( async function test_translate_selection_menuitem_is_unavailable_with_feature_disabled_and_clicking_a_hyperlink() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", false]], }); @@ -102,7 +102,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, + selectSpanishSentence: false, openAtSpanishHyperlink: true, expectMenuItemVisible: false, }, diff --git a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_full_page_translations_active.js b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_full_page_translations_active.js index 99cff2b4ec..788ca7de63 100644 --- a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_full_page_translations_active.js +++ b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_full_page_translations_active.js @@ -12,7 +12,7 @@ add_task( async function test_translate_selection_menuitem_with_text_selected_and_full_page_translations_active() { const { cleanup, resolveDownloads, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -27,8 +27,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, + selectSpanishSentence: true, + openAtSpanishSentence: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", }, @@ -52,8 +52,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, + selectSpanishSentence: true, + openAtSpanishSentence: true, expectMenuItemVisible: false, }, "The translate-selection context menu item should be unavailable while full-page translations is active." @@ -70,8 +70,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, + selectSpanishSentence: true, + openAtSpanishSentence: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", }, @@ -91,7 +91,7 @@ add_task( add_task( async function test_translate_selection_menuitem_with_link_clicked_and_full_page_translations_active() { const { cleanup, resolveDownloads, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -106,7 +106,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, + selectSpanishSentence: false, openAtSpanishHyperlink: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", @@ -131,7 +131,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, + selectSpanishSentence: false, openAtSpanishHyperlink: true, expectMenuItemVisible: false, }, @@ -149,7 +149,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, + selectSpanishSentence: false, openAtSpanishHyperlink: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", diff --git a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_hyperlink.js b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_hyperlink.js index cefd83f046..83e836489f 100644 --- a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_hyperlink.js +++ b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_hyperlink.js @@ -12,7 +12,7 @@ add_task( async function test_translate_selection_menuitem_translate_link_text_to_target_language() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -25,7 +25,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, + selectSpanishSentence: false, openAtSpanishHyperlink: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", @@ -47,7 +47,7 @@ add_task( add_task( async function test_translate_selection_menuitem_translate_link_text_in_preferred_language() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -60,7 +60,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, + selectSpanishSentence: false, openAtEnglishHyperlink: true, expectMenuItemVisible: true, expectedTargetLanguage: null, @@ -82,7 +82,7 @@ add_task( add_task( async function test_translate_selection_menuitem_selected_text_takes_precedence_over_link_text() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -95,7 +95,7 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: true, + selectSpanishSentence: true, openAtEnglishHyperlink: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", diff --git a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_no_text_selected.js b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_no_text_selected.js index 82e5d3ba63..5e7d482441 100644 --- a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_no_text_selected.js +++ b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_no_text_selected.js @@ -10,7 +10,7 @@ add_task( async function test_translate_selection_menuitem_is_unavailable_when_no_text_is_selected() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -25,8 +25,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: false, - openAtSpanishParagraph: true, + selectSpanishSentence: false, + openAtSpanishSentence: true, expectMenuItemVisible: false, }, "The translate-selection context menu item should be unavailable when no text is selected." diff --git a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_text_selected.js b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_text_selected.js index deb5911a37..6b44f2ca1f 100644 --- a/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_text_selected.js +++ b/browser/components/translations/tests/browser/browser_translations_select_context_menu_with_text_selected.js @@ -12,7 +12,7 @@ add_task( async function test_translate_selection_menuitem_when_selected_text_is_not_preferred_language() { const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); @@ -27,8 +27,8 @@ add_task( await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, + selectSpanishSentence: true, + openAtSpanishSentence: true, expectMenuItemVisible: true, expectedTargetLanguage: "en", }, @@ -49,21 +49,21 @@ add_task( add_task( async function test_translate_selection_menuitem_when_selected_text_is_preferred_language() { const { cleanup, runInPage } = await loadTestPage({ - page: ENGLISH_PAGE_URL, + page: SELECT_TEST_PAGE_URL, languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); await FullPageTranslationsTestUtils.assertTranslationsButton( - { button: false }, + { button: true, circleArrows: false, locale: false, icon: true }, "The button is available." ); await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, { - selectFirstParagraph: true, - openAtFirstParagraph: true, + selectEnglishSentence: true, + openAtEnglishSentence: true, expectMenuItemVisible: true, expectedTargetLanguage: null, }, diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_engine_cache.js b/browser/components/translations/tests/browser/browser_translations_select_panel_engine_cache.js new file mode 100644 index 0000000000..a0ef58c694 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_engine_cache.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests that the SelectTranslationsPanel successfully + * caches the engine within the Translator for the given language pair, + * and if that engine is destroyed, the Translator will correctly reinitialize + * the engine, even for the same language pair. + */ +add_task( + async function test_select_translations_panel_translate_sentence_on_open() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSection: true, + openAtFrenchSection: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + expectedDownloads: 1, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + // No downloads because the engine is cached for this language pair. + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + info("Explicitly destroying the Translations Engine."); + await destroyTranslationsEngine(); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtFrenchHyperlink: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + // Expect downloads again since the engine was destroyed. + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_fallback_to_doc_language.js b/browser/components/translations/tests/browser/browser_translations_select_panel_fallback_to_doc_language.js new file mode 100644 index 0000000000..d2c6f42486 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_fallback_to_doc_language.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the case of opening the SelectTranslationsPanel when the + * detected language is unsupported, but the page language is known to be a supported + * language. The panel should automatically fall back to the page language in an + * effort to combat falsely identified selections. + */ +add_task( + async function test_select_translations_panel_translate_sentence_on_open() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include French. + { fromLang: "es", toLang: "en" }, + { fromLang: "en", toLang: "es" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + // French is not supported, but the page is in Spanish, so expect Spanish. + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_language_selectors.js b/browser/components/translations/tests/browser/browser_translations_select_panel_language_selectors.js deleted file mode 100644 index 1dcc76450f..0000000000 --- a/browser/components/translations/tests/browser/browser_translations_select_panel_language_selectors.js +++ /dev/null @@ -1,54 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - http://creativecommons.org/publicdomain/zero/1.0/ */ - -"use strict"; - -add_task( - async function test_select_translations_panel_open_spanish_language_selectors() { - const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, - languagePairs: LANGUAGE_PAIRS, - prefs: [["browser.translations.select.enable", true]], - }); - - await SelectTranslationsTestUtils.openPanel(runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, - expectedTargetLanguage: "en", - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewDefault, - }); - - SelectTranslationsTestUtils.assertSelectedFromLanguage({ langTag: "es" }); - SelectTranslationsTestUtils.assertSelectedToLanguage({ langTag: "en" }); - - await SelectTranslationsTestUtils.clickDoneButton(); - - await cleanup(); - } -); - -add_task( - async function test_select_translations_panel_open_english_language_selectors() { - const { cleanup, runInPage } = await loadTestPage({ - page: ENGLISH_PAGE_URL, - languagePairs: LANGUAGE_PAIRS, - prefs: [["browser.translations.select.enable", true]], - }); - - await SelectTranslationsTestUtils.openPanel(runInPage, { - selectFirstParagraph: true, - openAtFirstParagraph: true, - expectedTargetLanguage: "en", - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewDefault, - }); - - SelectTranslationsTestUtils.assertSelectedFromLanguage({ langTag: "en" }); - SelectTranslationsTestUtils.assertSelectedToLanguage({ - l10nId: "translations-panel-choose-language", - }); - - await SelectTranslationsTestUtils.clickDoneButton(); - - await cleanup(); - } -); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_mainview_ui.js b/browser/components/translations/tests/browser/browser_translations_select_panel_mainview_ui.js deleted file mode 100644 index 79d21e57d0..0000000000 --- a/browser/components/translations/tests/browser/browser_translations_select_panel_mainview_ui.js +++ /dev/null @@ -1,36 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - http://creativecommons.org/publicdomain/zero/1.0/ */ - -"use strict"; - -/** - * This test case verifies the visibility and initial state of UI elements within the - * Select Translations Panel's main-view UI. - */ -add_task( - async function test_select_translations_panel_mainview_ui_element_visibility() { - const { cleanup, runInPage } = await loadTestPage({ - page: SPANISH_PAGE_URL, - languagePairs: LANGUAGE_PAIRS, - prefs: [["browser.translations.select.enable", true]], - }); - - await FullPageTranslationsTestUtils.assertTranslationsButton( - { button: true, circleArrows: false, locale: false, icon: true }, - "The button is available." - ); - - await FullPageTranslationsTestUtils.assertPageIsUntranslated(runInPage); - - await SelectTranslationsTestUtils.openPanel(runInPage, { - selectSpanishParagraph: true, - openAtSpanishParagraph: true, - expectedTargetLanguage: "es", - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewDefault, - }); - - await SelectTranslationsTestUtils.clickDoneButton(); - - await cleanup(); - } -); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_open_to_idle_state.js b/browser/components/translations/tests/browser/browser_translations_select_panel_open_to_idle_state.js new file mode 100644 index 0000000000..d5a1096e70 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_open_to_idle_state.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the select translations panel's functionality when opened with an unsupported + * from-language, ensuring it opens with the correct view with no from-language selected. + */ +add_task( + async function test_select_translations_panel_open_no_selected_from_lang() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSentence: true, + openAtSpanishSentence: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case verifies the select translations panel's functionality when opened with an undetermined + * to-language, ensuring it opens with the correct view with no to-language selected. + */ +add_task( + async function test_select_translations_panel_open_no_selected_to_lang() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSentence: true, + openAtEnglishSentence: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_retranslate_on_change_language_directly.js b/browser/components/translations/tests/browser/browser_translations_select_panel_retranslate_on_change_language_directly.js new file mode 100644 index 0000000000..fff0326f75 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_retranslate_on_change_language_directly.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of triggering a translation by directly switching + * the from-language when the panel is already in the "translated" state from a previous + * language pair. + */ +add_task( + async function test_select_translations_panel_retranslate_on_change_from_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: false, + pivotTranslation: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of triggering a translation by directly switching + * the to-language when the panel is already in the "translated" state from a previous + * language pair. + */ +add_task( + async function test_select_translations_panel_retranslate_on_change_to_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { + openDropdownMenu: false, + pivotTranslation: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_retranslate_on_change_language_from_dropdown_menu.js b/browser/components/translations/tests/browser/browser_translations_select_panel_retranslate_on_change_language_from_dropdown_menu.js new file mode 100644 index 0000000000..16f2cb39f7 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_retranslate_on_change_language_from_dropdown_menu.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of triggering a translation by switching the + * from-language by opening the language dropdown menu when the panel is already in + * the "translated" state from a previous language pair. + */ +add_task( + async function test_select_translations_panel_retranslate_on_change_from_language_via_popup() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtSpanishHyperlink: true, + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["uk"], { + openDropdownMenu: true, + pivotTranslation: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of triggering a translation by switching the + * to-language by opening the language dropdown menu when the panel is already in + * the "translated" state from a previous language pair. + */ +add_task( + async function test_select_translations_panel_retranslate_on_change_to_language_via_popup() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtSpanishHyperlink: true, + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["uk"], { + openDropdownMenu: true, + pivotTranslation: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_select_current_language_directly.js b/browser/components/translations/tests/browser/browser_translations_select_panel_select_current_language_directly.js new file mode 100644 index 0000000000..f45326800f --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_select_current_language_directly.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of directly switching the from-language to the same + * from-language that is already selected, ensuring no change occurs to the translation state, + * and that no re-translation is triggered. + */ +add_task( + async function test_select_translations_panel_select_current_from_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSentence: true, + openAtSpanishSentence: true, + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: false, + // No downloads are resolved, because no re-translation is triggered. + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of directly switching the to-language to the same + * to-language that is already selected, ensuring no change occurs to the translation state, + * and that no re-translation is triggered. + */ +add_task( + async function test_select_translations_panel_select_current_from_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtFrenchHyperlink: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["en"], { + openDropdownMenu: false, + // No downloads are resolved, because no re-translation is triggered. + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_select_current_language_from_dropdown_menu.js b/browser/components/translations/tests/browser/browser_translations_select_panel_select_current_language_from_dropdown_menu.js new file mode 100644 index 0000000000..04aa731cf2 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_select_current_language_from_dropdown_menu.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of directly switching the from-language to the same + * from-language that is already selected by opening the language dropdown menu, + * ensuring no change occurs to the translation state, and that no re-translation is triggered. + */ +add_task( + async function test_select_translations_panel_select_current_from_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSentence: true, + openAtSpanishSentence: true, + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: true, + // No downloads are resolved, because no re-translation is triggered. + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of directly switching the to-language to the same + * to-language that is already selected by opening the language dropdown menu, + * ensuring no change occurs to the translation state, and that no re-translation is triggered. + */ +add_task( + async function test_select_translations_panel_select_current_from_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtFrenchHyperlink: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["en"], { + openDropdownMenu: true, + // No downloads are resolved, because no re-translation is triggered. + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_select_same_from_and_to_languages_directly.js b/browser/components/translations/tests/browser/browser_translations_select_panel_select_same_from_and_to_languages_directly.js new file mode 100644 index 0000000000..95feac6708 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_select_same_from_and_to_languages_directly.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of switching the from-language to the same value + * that is currently selected in the to-language, effectively stealing the to-language's + * value, leaving it unselected and focused. + */ +add_task( + async function test_select_translations_panel_select_same_from_language_directly() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSection: true, + openAtSpanishSection: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["en"], { + openDropdownMenu: false, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewNoFromToSelected, + }); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of switching the to-language to the same value + * that is currently selected in the from-language, effectively stealing the from-language's + * value, leaving it unselected and focused. + */ +add_task( + async function test_select_translations_panel_select_same_to_language_directly() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSection: true, + openAtEnglishSection: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["en"], { + openDropdownMenu: false, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_select_same_from_and_to_languages_from_dropdown_menu.js b/browser/components/translations/tests/browser/browser_translations_select_panel_select_same_from_and_to_languages_from_dropdown_menu.js new file mode 100644 index 0000000000..5c27be411f --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_select_same_from_and_to_languages_from_dropdown_menu.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of switching the from-language to the same value + * that is currently selected in the to-language by opening the language dropdown menu, + * effectively stealing the to-language's value, leaving it unselected and focused. + */ +add_task( + async function test_select_translations_panel_select_same_from_language_via_popup() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSection: true, + openAtSpanishSection: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["en"], { + openDropdownMenu: true, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewNoFromToSelected, + }); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of switching the to-language to the same value + * that is currently selected in the from-language by opening the language dropdown menu, + * effectively stealing the from-language's value, leaving it unselected and focused. + */ +add_task( + async function test_select_translations_panel_select_same_to_language_via_popup() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSection: true, + openAtEnglishSection: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["en"], { + openDropdownMenu: true, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_directly.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_directly.js new file mode 100644 index 0000000000..64d067d1f4 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_directly.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of triggering a translation by directly switching + * the from-language to a valid selection when the panel is in the "idle" state without + * valid language pair. + */ +add_task( + async function test_select_translations_panel_translate_on_change_from_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSection: true, + openAtSpanishSection: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["fr"], { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of triggering a translation by directly switching + * the to-language to a valid selection when the panel is in the "idle" state without + * valid language pair. + */ +add_task( + async function test_select_translations_panel_translate_on_change_to_language_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSection: true, + openAtEnglishSection: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_from_dropdown_menu.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_from_dropdown_menu.js new file mode 100644 index 0000000000..0cd205d721 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_from_dropdown_menu.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of triggering a translation by switching the + * from-language to a valid selection by opening the language dropdown when the panel + * is in the "idle" state without valid language pair. + */ +add_task( + async function test_select_translations_panel_translate_on_change_from_language() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSection: true, + openAtSpanishSection: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["fr"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of triggering a translation by switching the + * to-language to a valid selection by opening the language dropdown when the panel + * is in the "idle" state without valid language pair. + */ +add_task( + async function test_select_translations_panel_translate_on_change_to_language() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSection: true, + openAtEnglishSection: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_multiple_times_directly.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_multiple_times_directly.js new file mode 100644 index 0000000000..b3c02a96f6 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_multiple_times_directly.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of directly changing the from-language in rapid succession, + * ensuring that any triggered translations are resolved/dropped in order, and that the final translated + * state matches the final selected language. + */ +add_task( + async function test_select_translations_panel_translate_on_change_from_language_multiple_times_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fa", toLang: "en" }, + { fromLang: "en", toLang: "fa" }, + { fromLang: "fi", toLang: "en" }, + { fromLang: "en", toLang: "fi" }, + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + { fromLang: "sl", toLang: "en" }, + { fromLang: "en", toLang: "sl" }, + { fromLang: "uk", toLang: "en" }, + { fromLang: "en", toLang: "uk" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSentence: true, + openAtSpanishSentence: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage( + ["fa", "fi", "fr", "sl", "uk"], + { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + } + ); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of directly changing the to-language in rapid succession, + * ensuring that any triggered translations are resolved/dropped in order, and that the final translated + * state matches the final selected language. + */ +add_task( + async function test_select_translations_panel_translate_on_change_to_language_multiple_times_directly() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + { fromLang: "es", toLang: "en" }, + { fromLang: "en", toLang: "es" }, + { fromLang: "fa", toLang: "en" }, + { fromLang: "en", toLang: "fa" }, + { fromLang: "fi", toLang: "en" }, + { fromLang: "en", toLang: "fi" }, + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + { fromLang: "sl", toLang: "en" }, + { fromLang: "en", toLang: "sl" }, + { fromLang: "uk", toLang: "en" }, + { fromLang: "en", toLang: "uk" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtEnglishHyperlink: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage( + ["es", "fa", "fi", "fr", "sl", "uk", "fa"], + { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + } + ); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_multiple_times_from_dropdown_menu.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_multiple_times_from_dropdown_menu.js new file mode 100644 index 0000000000..50c877cfbc --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_change_language_multiple_times_from_dropdown_menu.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of directly changing the from-language in rapid succession + * by opening the language dropdown menu, ensuring that any triggered translations are resolved/dropped + * in order, and that the final translated state matches the final selected language. + */ +add_task( + async function test_select_translations_panel_translate_on_change_from_language_multiple_times_via_popup() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + // Do not include Spanish. + { fromLang: "fa", toLang: "en" }, + { fromLang: "en", toLang: "fa" }, + { fromLang: "fi", toLang: "en" }, + { fromLang: "en", toLang: "fi" }, + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + { fromLang: "sl", toLang: "en" }, + { fromLang: "en", toLang: "sl" }, + { fromLang: "uk", toLang: "en" }, + { fromLang: "en", toLang: "uk" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSentence: true, + openAtSpanishSentence: true, + expectedFromLanguage: null, + expectedToLanguage: "en", + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage( + ["fa", "fi", "fr", "sl", "uk"], + { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + } + ); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of directly changing the to-language in rapid succession + * by opening the language dropdown menu, ensuring that any triggered translations are resolved/dropped + * in order, and that the final translated state matches the final selected language. + */ +add_task( + async function test_select_translations_panel_translate_on_change_to_language_multiple_times_via_popup() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: [ + { fromLang: "es", toLang: "en" }, + { fromLang: "en", toLang: "es" }, + { fromLang: "fa", toLang: "en" }, + { fromLang: "en", toLang: "fa" }, + { fromLang: "fi", toLang: "en" }, + { fromLang: "en", toLang: "fi" }, + { fromLang: "fr", toLang: "en" }, + { fromLang: "en", toLang: "fr" }, + { fromLang: "sl", toLang: "en" }, + { fromLang: "en", toLang: "sl" }, + { fromLang: "uk", toLang: "en" }, + { fromLang: "en", toLang: "uk" }, + ], + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSentence: true, + openAtEnglishSentence: true, + expectedFromLanguage: "en", + expectedToLanguage: null, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage( + ["es", "fa", "fi", "fr", "sl", "uk"], + { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + } + ); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_open.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_open.js new file mode 100644 index 0000000000..7c7d6d88c9 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_on_open.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the case of opening the SelectTranslationsPanel to a valid + * language pair from a short selection of text, which should trigger a translation + * on panel open. + */ +add_task( + async function test_select_translations_panel_translate_sentence_on_open() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case tests the case of opening the SelectTranslationsPanel to a valid + * language pair from hyperlink text, which should trigger a translation on panel open. + */ +add_task( + async function test_select_translations_panel_translate_link_text_on_open() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + openAtSpanishHyperlink: true, + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case tests the case of opening the SelectTranslationsPanel to a valid + * language pair from a long selection of text, which should trigger a translation + * on panel open. + */ +add_task( + async function test_select_translations_panel_translate_long_text_on_open() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSection: true, + openAtFrenchSection: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/head.js b/browser/components/translations/tests/browser/head.js index 200ed08719..454de9146b 100644 --- a/browser/components/translations/tests/browser/head.js +++ b/browser/components/translations/tests/browser/head.js @@ -18,7 +18,7 @@ async function addTab(url) { const tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, url, - true // Wait for laod + true // Wait for load ); return { tab, @@ -65,7 +65,6 @@ function click(element, message) { */ function getAllByL10nId(l10nId, doc = document) { const elements = doc.querySelectorAll(`[data-l10n-id="${l10nId}"]`); - console.log(doc); if (elements.length === 0) { throw new Error("Could not find the element by l10n id: " + l10nId); } @@ -285,6 +284,19 @@ async function toggleReaderMode() { */ class SharedTranslationsTestUtils { /** + * Asserts that the specified element currently has focus. + * + * @param {Element} element - The element to check for focus. + */ + static _assertHasFocus(element) { + is( + document.activeElement, + element, + `The element '${element.id}' should have focus.` + ); + } + + /** * Asserts that the mainViewId of the panel matches the given string. * * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel @@ -300,53 +312,30 @@ class SharedTranslationsTestUtils { } /** - * Asserts that the selected from-language matches the provided arguments. + * Asserts that the selected language in the menu matches the langTag or l10nId. * - * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel - * - The UI component or panel whose selected from-language is being asserted. - * @param {object} options - An object containing assertion parameters. - * @param {string} [options.langTag] - A BCP-47 language tag. - * @param {string} [options.l10nId] - A localization identifier. + * @param {Element} menuList - The menu list element to check. + * @param {object} options - Options containing 'langTag' and 'l10nId' to assert against. + * @param {string} [options.langTag] - The BCP-47 language tag to match. + * @param {string} [options.l10nId] - The localization Id to match. */ - static _assertSelectedFromLanguage(panel, { langTag, l10nId }) { - const { fromMenuList } = panel.elements; - is( - fromMenuList.value, - langTag, - "Expected selected from-language to match the given language tag" + static _assertSelectedLanguage(menuList, { langTag, l10nId }) { + ok( + menuList.label, + `The label for the menulist ${menuList.id} should not be empty.` ); - if (l10nId) { - is( - fromMenuList.getAttribute("data-l10n-id"), - l10nId, - "Expected selected from-language to match the given l10n id" - ); - } - } - - /** - * Asserts that the selected to-language matches the provided arguments. - * - * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel - * - The UI component or panel whose selected from-language is being asserted. - * @param {object} options - An object containing assertion parameters. - * @param {string} [options.langTag] - A BCP-47 language tag. - * @param {string} [options.l10nId] - A localization identifier. - */ - static _assertSelectedToLanguage(panel, { langTag, l10nId }) { - const { toMenuList } = panel.elements; if (langTag) { is( - toMenuList.value, + menuList.value, langTag, - "Expected selected to-language to match the given language tag" + `Expected ${menuList.id} selection to match '${langTag}'` ); } if (l10nId) { is( - toMenuList.getAttribute("data-l10n-id"), + menuList.getAttribute("data-l10n-id"), l10nId, - "Expected selected to-language to match the given l10n id" + `Expected ${menuList.id} l10nId to match '${l10nId}'` ); } } @@ -391,6 +380,7 @@ class SharedTranslationsTestUtils { * This is often used to trigger the event on the expected element. * @param {Function|null} [postEventAssertion=null] - An optional callback function to execute after * the event has occurred. + * @param {ChromeWindow} [win] * @throws Throws if the element with the specified `elementId` does not exist. * @returns {Promise<void>} */ @@ -398,18 +388,21 @@ class SharedTranslationsTestUtils { elementId, eventName, callback, - postEventAssertion = null + postEventAssertion = null, + win = window ) { - const element = document.getElementById(elementId); + const element = win.document.getElementById(elementId); if (!element) { - throw new Error("Unable to find the translations panel element."); + throw new Error( + `Unable to find the ${elementId} element in the document.` + ); } const promise = BrowserTestUtils.waitForEvent(element, eventName); await callback(); - info("Waiting for the translations panel popup to be shown"); + info(`Waiting for the ${elementId} ${eventName} event`); await promise; if (postEventAssertion) { - postEventAssertion(); + await postEventAssertion(); } // Wait a single tick on the event loop. await new Promise(resolve => setTimeout(resolve, 0)); @@ -568,10 +561,12 @@ class FullPageTranslationsTestUtils { * * @param {string} fromLanguage - The BCP-47 language tag being translated from. * @param {string} toLanguage - The BCP-47 language tag being translated into. + * @param {ChromeWindow} win */ - static async #assertLangTagIsShownOnTranslationsButton( + static async assertLangTagIsShownOnTranslationsButton( fromLanguage, - toLanguage + toLanguage, + win = window ) { info( `Ensuring that the translations button displays the language tag "${toLanguage}"` @@ -579,7 +574,8 @@ class FullPageTranslationsTestUtils { const { button, locale } = await FullPageTranslationsTestUtils.assertTranslationsButton( { button: true, circleArrows: false, locale: true, icon: true }, - "The icon presents the locale." + "The icon presents the locale.", + win ); is( locale.innerText, @@ -605,12 +601,14 @@ class FullPageTranslationsTestUtils { * @param {string} toLanguage - The BCP-47 language tag being translated into. * @param {Function} runInPage - Allows running a closure in the content page. * @param {string} message - An optional message to log to info. + * @param {ChromeWindow} [win] */ static async assertPageIsTranslated( fromLanguage, toLanguage, runInPage, - message = null + message = null, + win = window ) { if (message) { info(message); @@ -625,9 +623,10 @@ class FullPageTranslationsTestUtils { ); }; await runInPage(callback, { fromLang: fromLanguage, toLang: toLanguage }); - await FullPageTranslationsTestUtils.#assertLangTagIsShownOnTranslationsButton( + await FullPageTranslationsTestUtils.assertLangTagIsShownOnTranslationsButton( fromLanguage, - toLanguage + toLanguage, + win ); } @@ -668,6 +667,9 @@ class FullPageTranslationsTestUtils { changeSourceLanguageButton: false, dismissErrorButton: false, error: false, + errorMessage: false, + errorMessageHint: false, + errorHintAction: false, fromMenuList: false, fromLabel: false, header: false, @@ -744,6 +746,34 @@ class FullPageTranslationsTestUtils { } /** + * Asserts that panel element visibility matches the initialization-failure view. + */ + static assertPanelViewInitFailure() { + info("Checking that the panel shows the default view"); + const { translateButton } = FullPageTranslationsPanel.elements; + FullPageTranslationsTestUtils.#assertPanelMainViewId( + "full-page-translations-panel-view-default" + ); + FullPageTranslationsTestUtils.#assertPanelElementVisibility({ + cancelButton: true, + error: true, + errorMessage: true, + errorMessageHint: true, + errorHintAction: true, + header: true, + translateButton: true, + }); + is( + translateButton.disabled, + true, + "The translate button should be disabled." + ); + FullPageTranslationsTestUtils.#assertPanelHeaderL10nId( + "translations-panel-header" + ); + } + + /** * Asserts that panel element visibility matches the panel error view. */ static assertPanelViewError() { @@ -753,6 +783,7 @@ class FullPageTranslationsTestUtils { ); FullPageTranslationsTestUtils.#assertPanelElementVisibility({ error: true, + errorMessage: true, ...FullPageTranslationsTestUtils.#defaultViewVisibilityExpectations, }); FullPageTranslationsTestUtils.#assertPanelHeaderL10nId( @@ -854,25 +885,31 @@ class FullPageTranslationsTestUtils { /** * Asserts that the selected from-language matches the provided language tag. * - * @param {string} langTag - A BCP-47 language tag. + * @param {object} options - Options containing 'langTag' and 'l10nId' to assert against. + * @param {string} [options.langTag] - The BCP-47 language tag to match. + * @param {string} [options.l10nId] - The localization Id to match. */ static assertSelectedFromLanguage({ langTag, l10nId }) { - SharedTranslationsTestUtils._assertSelectedFromLanguage( - FullPageTranslationsPanel, - { langTag, l10nId } - ); + const { fromMenuList } = FullPageTranslationsPanel.elements; + SharedTranslationsTestUtils._assertSelectedLanguage(fromMenuList, { + langTag, + l10nId, + }); } /** * Asserts that the selected to-language matches the provided language tag. * - * @param {string} langTag - A BCP-47 language tag. + * @param {object} options - Options containing 'langTag' and 'l10nId' to assert against. + * @param {string} [options.langTag] - The BCP-47 language tag to match. + * @param {string} [options.l10nId] - The localization Id to match. */ static assertSelectedToLanguage({ langTag, l10nId }) { - SharedTranslationsTestUtils._assertSelectedToLanguage( - FullPageTranslationsPanel, - { langTag, l10nId } - ); + const { toMenuList } = FullPageTranslationsPanel.elements; + SharedTranslationsTestUtils._assertSelectedLanguage(toMenuList, { + langTag, + l10nId, + }); } /** @@ -880,16 +917,21 @@ class FullPageTranslationsTestUtils { * * @param {Record<string, boolean>} visibleAssertions * @param {string} message The message for the assertion. + * @param {ChromeWindow} [win] * @returns {HTMLElement} */ - static async assertTranslationsButton(visibleAssertions, message) { + static async assertTranslationsButton( + visibleAssertions, + message, + win = window + ) { const elements = { - button: document.getElementById("translations-button"), - icon: document.getElementById("translations-button-icon"), - circleArrows: document.getElementById( + button: win.document.getElementById("translations-button"), + icon: win.document.getElementById("translations-button-icon"), + circleArrows: win.document.getElementById( "translations-button-circle-arrows" ), - locale: document.getElementById("translations-button-locale"), + locale: win.document.getElementById("translations-button-locale"), }; for (const [name, element] of Object.entries(elements)) { @@ -1083,25 +1125,31 @@ class FullPageTranslationsTestUtils { * @param {boolean} config.pivotTranslation * - True if the expected translation is a pivot translation, otherwise false. * Affects the number of expected downloads. + * @param {ChromeWindow} [config.win] + * - An optional ChromeWindow, for multi-window tests. */ static async clickTranslateButton({ downloadHandler = null, pivotTranslation = false, + win = window, } = {}) { logAction(); - const { translateButton } = FullPageTranslationsPanel.elements; + const { translateButton } = win.FullPageTranslationsPanel.elements; assertVisibility({ visible: { translateButton } }); await FullPageTranslationsTestUtils.waitForPanelPopupEvent( "popuphidden", () => { click(translateButton); - } + }, + null /* postEventAssertion */, + win ); if (downloadHandler) { await FullPageTranslationsTestUtils.assertTranslationsButton( { button: true, circleArrows: true, locale: false, icon: true }, - "The icon presents the loading indicator." + "The icon presents the loading indicator.", + win ); await downloadHandler(pivotTranslation ? 2 : 1); } @@ -1117,21 +1165,26 @@ class FullPageTranslationsTestUtils { * - Open the panel from the app menu. If false, uses the translations button. * @param {boolean} config.openWithKeyboard * - Open the panel by synthesizing the keyboard. If false, synthesizes the mouse. + * @param {ChromeWindow} [config.win] + * - An optional window for multi-window tests. */ static async openPanel({ onOpenPanel = null, openFromAppMenu = false, openWithKeyboard = false, + win = window, }) { logAction(); - await closeAllOpenPanelsAndMenus(); + await closeAllOpenPanelsAndMenus(win); if (openFromAppMenu) { await FullPageTranslationsTestUtils.#openPanelViaAppMenu({ + win, onOpenPanel, openWithKeyboard, }); } else { await FullPageTranslationsTestUtils.#openPanelViaTranslationsButton({ + win, onOpenPanel, openWithKeyboard, }); @@ -1146,21 +1199,26 @@ class FullPageTranslationsTestUtils { * - A function to run as soon as the panel opens. * @param {boolean} config.openWithKeyboard * - Open the panel by synthesizing the keyboard. If false, synthesizes the mouse. + * @param {ChromeWindow} [config.win] */ static async #openPanelViaAppMenu({ onOpenPanel = null, openWithKeyboard = false, + win = window, }) { logAction(); - const appMenuButton = getById("PanelUI-menu-button"); + const appMenuButton = getById("PanelUI-menu-button", win.document); if (openWithKeyboard) { hitEnterKey(appMenuButton, "Opening the app-menu button with keyboard"); } else { click(appMenuButton, "Opening the app-menu button"); } - await BrowserTestUtils.waitForEvent(window.PanelUI.mainView, "ViewShown"); + await BrowserTestUtils.waitForEvent(win.PanelUI.mainView, "ViewShown"); - const translateSiteButton = getById("appMenu-translate-button"); + const translateSiteButton = getById( + "appMenu-translate-button", + win.document + ); is( translateSiteButton.disabled, @@ -1189,16 +1247,19 @@ class FullPageTranslationsTestUtils { * - A function to run as soon as the panel opens. * @param {boolean} config.openWithKeyboard * - Open the panel by synthesizing the keyboard. If false, synthesizes the mouse. + * @param {ChromeWindow} [config.win] */ static async #openPanelViaTranslationsButton({ onOpenPanel = null, openWithKeyboard = false, + win = window, }) { logAction(); const { button } = await FullPageTranslationsTestUtils.assertTranslationsButton( { button: true }, - "The translations button is visible." + "The translations button is visible.", + win ); await FullPageTranslationsTestUtils.waitForPanelPopupEvent( "popupshown", @@ -1209,7 +1270,8 @@ class FullPageTranslationsTestUtils { click(button, "Opening the popup"); } }, - onOpenPanel + onOpenPanel, + win ); } @@ -1242,7 +1304,7 @@ class FullPageTranslationsTestUtils { * * @param {string} langTag - A BCP-47 language tag. */ - static switchSelectedFromLanguage(langTag) { + static changeSelectedFromLanguage(langTag) { logAction(langTag); const { fromMenuList } = FullPageTranslationsPanel.elements; fromMenuList.value = langTag; @@ -1254,7 +1316,7 @@ class FullPageTranslationsTestUtils { * * @param {string} langTag - A BCP-47 language tag. */ - static switchSelectedToLanguage(langTag) { + static changeSelectedToLanguage(langTag) { logAction(langTag); const { toMenuList } = FullPageTranslationsPanel.elements; toMenuList.value = langTag; @@ -1270,20 +1332,23 @@ class FullPageTranslationsTestUtils { * @param {Function} callback * @param {Function} postEventAssertion * An optional assertion to be made immediately after the event occurs. + * @param {ChromeWindow} [win] * @returns {Promise<void>} */ static async waitForPanelPopupEvent( eventName, callback, - postEventAssertion = null + postEventAssertion = null, + win = window ) { // De-lazify the panel elements. - FullPageTranslationsPanel.elements; + win.FullPageTranslationsPanel.elements; await SharedTranslationsTestUtils._waitForPopupEvent( "full-page-translations-panel", eventName, callback, - postEventAssertion + postEventAssertion, + win ); } } @@ -1297,31 +1362,47 @@ class SelectTranslationsTestUtils { * * @param {Function} runInPage - A content-exposed function to run within the context of the page. * @param {object} options - Options for how to open the context menu and what properties to assert about the translate-selection item. - * @param {boolean} options.selectFirstParagraph - Selects the first paragraph before opening the context menu. - * @param {boolean} options.selectSpanishParagraph - Selects the Spanish paragraph before opening the context menu. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.expectMenuItemIsVisible - Whether the translate-selection item is expected to be visible. - * Does not assert visibility if left undefined. - * @param {string} options.expectedTargetLanguage - The target language for translation. - * @param {boolean} options.openAtFirstParagraph - Opens the context menu at the first paragraph in the test page. - * @param {boolean} options.openAtSpanishParagraph - Opens the context menu at the Spanish paragraph in the test page. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at the English hyperlink in the test page. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at the Spanish hyperlink in the test page. - * This is only available in SPANISH_TEST_PAGE. + * + * The following options will only work when testing SELECT_TEST_PAGE_URL. + * + * @param {boolean} options.expectMenuItemVisible - Whether the select-translations menu item should be present in the context menu. + * @param {boolean} options.expectedTargetLanguage - The expected target language to be shown in the context menu. + * @param {boolean} options.selectFrenchSection - Selects the section of French text. + * @param {boolean} options.selectEnglishSection - Selects the section of English text. + * @param {boolean} options.selectSpanishSection - Selects the section of Spanish text. + * @param {boolean} options.selectFrenchSentence - Selects a French sentence. + * @param {boolean} options.selectEnglishSentence - Selects an English sentence. + * @param {boolean} options.selectSpanishSentence - Selects a Spanish sentence. + * @param {boolean} options.openAtFrenchSection - Opens the context menu at the section of French text. + * @param {boolean} options.openAtEnglishSection - Opens the context menu at the section of English text. + * @param {boolean} options.openAtSpanishSection - Opens the context menu at the section of Spanish text. + * @param {boolean} options.openAtFrenchSentence - Opens the context menu at a French sentence. + * @param {boolean} options.openAtEnglishSentence - Opens the context menu at an English sentence. + * @param {boolean} options.openAtSpanishSentence - Opens the context menu at a Spanish sentence. + * @param {boolean} options.openAtFrenchHyperlink - Opens the context menu at a hyperlinked French text. + * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at an hyperlinked English text. + * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at a hyperlinked Spanish text. * @param {string} [message] - A message to log to info. * @throws Throws an error if the properties of the translate-selection item do not match the expected options. */ static async assertContextMenuTranslateSelectionItem( runInPage, { - selectFirstParagraph, - selectSpanishParagraph, - expectMenuItemIsVisible, + expectMenuItemVisible, expectedTargetLanguage, - openAtFirstParagraph, - openAtSpanishParagraph, + selectFrenchSection, + selectEnglishSection, + selectSpanishSection, + selectFrenchSentence, + selectEnglishSentence, + selectSpanishSentence, + openAtFrenchSection, + openAtEnglishSection, + openAtSpanishSection, + openAtFrenchSentence, + openAtEnglishSentence, + openAtSpanishSentence, + openAtFrenchHyperlink, openAtEnglishHyperlink, openAtSpanishHyperlink, }, @@ -1336,10 +1417,21 @@ class SelectTranslationsTestUtils { await closeAllOpenPanelsAndMenus(); await SelectTranslationsTestUtils.openContextMenu(runInPage, { - selectFirstParagraph, - selectSpanishParagraph, - openAtFirstParagraph, - openAtSpanishParagraph, + expectMenuItemVisible, + expectedTargetLanguage, + selectFrenchSection, + selectEnglishSection, + selectSpanishSection, + selectFrenchSentence, + selectEnglishSentence, + selectSpanishSentence, + openAtFrenchSection, + openAtEnglishSection, + openAtSpanishSection, + openAtFrenchSentence, + openAtEnglishSentence, + openAtSpanishSentence, + openAtFrenchHyperlink, openAtEnglishHyperlink, openAtSpanishHyperlink, }); @@ -1349,16 +1441,21 @@ class SelectTranslationsTestUtils { /* ensureIsVisible */ false ); - if (expectMenuItemIsVisible !== undefined) { - const visibility = expectMenuItemIsVisible ? "visible" : "hidden"; - assertVisibility({ [visibility]: menuItem }); + if (expectMenuItemVisible !== undefined) { + const visibility = expectMenuItemVisible ? "visible" : "hidden"; + assertVisibility({ [visibility]: { menuItem } }); } - if (expectMenuItemIsVisible === true) { + if (expectMenuItemVisible === true) { if (expectedTargetLanguage) { // Target language expected, check for the data-l10n-id with a `{$language}` argument. const expectedL10nId = - selectFirstParagraph === true || selectSpanishParagraph === true + selectFrenchSection || + selectEnglishSection || + selectSpanishSection || + selectFrenchSentence || + selectEnglishSentence || + selectSpanishSentence ? "main-context-menu-translate-selection-to-language" : "main-context-menu-translate-link-text-to-language"; await waitForCondition( @@ -1381,7 +1478,12 @@ class SelectTranslationsTestUtils { } else { // No target language expected, check for the data-l10n-id that has no `{$language}` argument. const expectedL10nId = - selectFirstParagraph === true || selectSpanishParagraph === true + selectFrenchSection || + selectEnglishSection || + selectSpanishSection || + selectFrenchSentence || + selectEnglishSentence || + selectSpanishSentence ? "main-context-menu-translate-selection" : "main-context-menu-translate-link-text"; await waitForCondition( @@ -1399,6 +1501,21 @@ class SelectTranslationsTestUtils { } /** + * Elements that should always be visible in the SelectTranslationsPanel. + */ + static #alwaysPresentElements = { + betaIcon: true, + copyButton: true, + doneButton: true, + fromLabel: true, + fromMenuList: true, + header: true, + toLabel: true, + toMenuList: true, + textArea: true, + }; + + /** * Asserts that for each provided expectation, the visible state of the corresponding * element in FullPageTranslationsPanel.elements both exists and matches the visibility expectation. * @@ -1409,16 +1526,7 @@ class SelectTranslationsTestUtils { SharedTranslationsTestUtils._assertPanelElementVisibility( SelectTranslationsPanel.elements, { - betaIcon: false, - copyButton: false, - doneButton: false, - fromLabel: false, - fromMenuList: false, - header: false, - textArea: false, - toLabel: false, - toMenuList: false, - translateFullPageButton: false, + ...SelectTranslationsTestUtils.#alwaysPresentElements, // Overwrite any of the above defaults with the passed in expectations. ...expectations, } @@ -1426,49 +1534,255 @@ class SelectTranslationsTestUtils { } /** - * Asserts that the mainViewId of the panel matches the given string. - * - * @param {string} expectedId + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when the panel has completed its translation. */ - static #assertPanelMainViewId(expectedId) { - SharedTranslationsTestUtils._assertPanelMainViewId( - SelectTranslationsPanel, - expectedId + static assertPanelViewTranslated() { + const { textArea } = SelectTranslationsPanel.elements; + ok( + !textArea.classList.contains("translating"), + "The textarea should not have the translating class." + ); + SelectTranslationsTestUtils.#assertPanelElementVisibility({ + ...SelectTranslationsTestUtils.#alwaysPresentElements, + }); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ + textArea: true, + copyButton: true, + translateFullPageButton: true, + }); + SelectTranslationsTestUtils.#assertPanelHasTranslatedText(); + SelectTranslationsTestUtils.#assertPanelTextAreaHeight(); + SelectTranslationsTestUtils.#assertPanelTextAreaOverflow(); + } + + static #assertPanelTextAreaDirection(langTag = null) { + const expectedTextDirection = langTag + ? Services.intl.getScriptDirection(langTag) + : null; + const { textArea } = SelectTranslationsPanel.elements; + const actualTextDirection = textArea.getAttribute("dir"); + + is( + actualTextDirection, + expectedTextDirection, + `The text direction should be ${expectedTextDirection}` ); } /** - * Asserts that panel element visibility matches the default panel view. + * Asserts that the SelectTranslationsPanel translated text area is + * both scrollable and scrolled to the top. */ - static assertPanelViewDefault() { - info("Checking that the select-translations panel shows the default view"); - SelectTranslationsTestUtils.#assertPanelMainViewId( - "select-translations-panel-view-default" + static #assertPanelTextAreaOverflow() { + const { textArea } = SelectTranslationsPanel.elements; + is( + textArea.style.overflow, + "auto", + "The translated-text area should be scrollable." + ); + if (textArea.scrollHeight > textArea.clientHeight) { + is( + textArea.scrollTop, + 0, + "The translated-text area should be scrolled to the top." + ); + } + } + + /** + * Asserts that the SelectTranslationsPanel translated text area is + * the correct height for the length of the translated text. + */ + static #assertPanelTextAreaHeight() { + const { textArea } = SelectTranslationsPanel.elements; + + if ( + SelectTranslationsPanel.getSourceText().length < + SelectTranslationsPanel.textLengthThreshold + ) { + is( + textArea.style.height, + SelectTranslationsPanel.shortTextHeight, + "The panel text area should have the short-text height" + ); + } else { + is( + textArea.style.height, + SelectTranslationsPanel.longTextHeight, + "The panel text area should have the long-text height" + ); + } + } + + /** + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when the panel is actively translating text. + */ + static assertPanelViewActivelyTranslating() { + const { textArea } = SelectTranslationsPanel.elements; + ok( + textArea.classList.contains("translating"), + "The textarea should have the translating class." ); SelectTranslationsTestUtils.#assertPanelElementVisibility({ - betaIcon: true, - fromLabel: true, - fromMenuList: true, - header: true, + ...SelectTranslationsTestUtils.#alwaysPresentElements, + }); + SelectTranslationsTestUtils.#assertPanelHasTranslatingPlaceholder(); + } + + /** + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when no from-language is selected in the panel. + */ + static async assertPanelViewNoFromLangSelected() { + const { textArea } = SelectTranslationsPanel.elements; + ok( + !textArea.classList.contains("translating"), + "The textarea should not have the translating class." + ); + SelectTranslationsTestUtils.#assertPanelElementVisibility({ + ...SelectTranslationsTestUtils.#alwaysPresentElements, + }); + await SelectTranslationsTestUtils.#assertPanelHasIdlePlaceholder(); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ + textArea: false, + copyButton: false, + translateFullPageButton: false, + }); + SelectTranslationsTestUtils.assertSelectedFromLanguage(null); + } + + /** + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when no to-language is selected in the panel. + */ + static async assertPanelViewNoToLangSelected() { + const { textArea } = SelectTranslationsPanel.elements; + ok( + !textArea.classList.contains("translating"), + "The textarea should not have the translating class." + ); + SelectTranslationsTestUtils.#assertPanelElementVisibility({ + ...SelectTranslationsTestUtils.#alwaysPresentElements, + }); + SelectTranslationsTestUtils.assertSelectedToLanguage(null); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ + textArea: false, + copyButton: false, + translateFullPageButton: false, + }); + await SelectTranslationsTestUtils.#assertPanelHasIdlePlaceholder(); + } + + /** + * Asserts that the SelectTranslationsPanel UI contains the + * idle placeholder text. + */ + static async #assertPanelHasIdlePlaceholder() { + const { textArea } = SelectTranslationsPanel.elements; + const expected = await document.l10n.formatValue( + "select-translations-panel-idle-placeholder-text" + ); + is( + textArea.value, + expected, + "Translated text area should be the idle placeholder." + ); + SelectTranslationsTestUtils.#assertPanelTextAreaDirection(); + } + + /** + * Asserts that the SelectTranslationsPanel UI contains the + * translating placeholder text. + */ + static async #assertPanelHasTranslatingPlaceholder() { + const { textArea } = SelectTranslationsPanel.elements; + const expected = await document.l10n.formatValue( + "select-translations-panel-translating-placeholder-text" + ); + is( + textArea.value, + expected, + "Active translation text area should have the translating placeholder." + ); + SelectTranslationsTestUtils.#assertPanelTextAreaDirection(); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ + textArea: true, + copyButton: false, + translateFullPageButton: true, + }); + } + + /** + * Asserts that the SelectTranslationsPanel UI contains the + * translated text. + */ + static #assertPanelHasTranslatedText() { + const { textArea, fromMenuList, toMenuList } = + SelectTranslationsPanel.elements; + const fromLanguage = fromMenuList.value; + const toLanguage = toMenuList.value; + const translatedSuffix = ` [${fromLanguage} to ${toLanguage}]`; + ok( + textArea.value.endsWith(translatedSuffix), + `Translated text should match ${fromLanguage} to ${toLanguage}` + ); + is( + SelectTranslationsPanel.getSourceText().length, + SelectTranslationsPanel.getTranslatedText().length - + translatedSuffix.length, + "Expected translated text length to correspond to the source text length." + ); + SelectTranslationsTestUtils.#assertPanelTextAreaDirection(toLanguage); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ textArea: true, - toLabel: true, - toMenuList: true, copyButton: true, - doneButton: true, translateFullPageButton: true, }); } /** + * Asserts the enabled state of action buttons in the SelectTranslationsPanel. + * + * @param {boolean} expectEnabled - Whether the buttons should be enabled (true) or not (false). + */ + static #assertConditionalUIEnabled({ + copyButton: copyButtonEnabled, + translateFullPageButton: translateFullPageButtonEnabled, + textArea: textAreaEnabled, + }) { + const { copyButton, translateFullPageButton, textArea } = + SelectTranslationsPanel.elements; + is( + copyButton.disabled, + !copyButtonEnabled, + `The copy button should be ${copyButtonEnabled ? "enabled" : "disabled"}.` + ); + is( + translateFullPageButton.disabled, + !translateFullPageButtonEnabled, + `The translate-full-page button should be ${ + translateFullPageButtonEnabled ? "enabled" : "disabled" + }.` + ); + is( + textArea.disabled, + !textAreaEnabled, + `The translated-text area should be ${ + textAreaEnabled ? "enabled" : "disabled" + }.` + ); + } + + /** * Asserts that the selected from-language matches the provided language tag. * * @param {string} langTag - A BCP-47 language tag. */ - static assertSelectedFromLanguage({ langTag, l10nId }) { - SharedTranslationsTestUtils._assertSelectedFromLanguage( - SelectTranslationsPanel, - { langTag, l10nId } - ); + static assertSelectedFromLanguage(langTag = null) { + const { fromMenuList } = SelectTranslationsPanel.elements; + SelectTranslationsTestUtils.#assertSelectedLanguage(fromMenuList, langTag); } /** @@ -1476,11 +1790,29 @@ class SelectTranslationsTestUtils { * * @param {string} langTag - A BCP-47 language tag. */ - static assertSelectedToLanguage({ langTag, l10nId }) { - SharedTranslationsTestUtils._assertSelectedToLanguage( - SelectTranslationsPanel, - { langTag, l10nId } - ); + static assertSelectedToLanguage(langTag = null) { + const { toMenuList } = SelectTranslationsPanel.elements; + SelectTranslationsTestUtils.#assertSelectedLanguage(toMenuList, langTag); + } + + /** + * Asserts the selected language in the given menu list if a langTag is provided. + * If no langTag is given, asserts that the menulist displays the localized placeholder. + * + * @param {object} menuList - The menu list object to check. + * @param {string} [langTag] - The optional language tag to assert against. + */ + static #assertSelectedLanguage(menuList, langTag) { + if (langTag) { + SharedTranslationsTestUtils._assertSelectedLanguage(menuList, { + langTag, + }); + } else { + SharedTranslationsTestUtils._assertSelectedLanguage(menuList, { + l10nId: "translations-panel-choose-language", + }); + SharedTranslationsTestUtils._assertHasFocus(menuList); + } } /** @@ -1503,110 +1835,258 @@ class SelectTranslationsTestUtils { * * @param {Function} runInPage - A content-exposed function to run within the context of the page. * @param {object} options - Options for opening the context menu. - * @param {boolean} options.selectFirstParagraph - Selects the first paragraph before opening the context menu. - * @param {boolean} options.selectSpanishParagraph - Selects the Spanish paragraph before opening the context menu. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtFirstParagraph - Opens the context menu at the first paragraph in the test page. - * @param {boolean} options.openAtSpanishParagraph - Opens the context menu at the Spanish paragraph in the test page. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at the English hyperlink in the test page. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at the Spanish hyperlink in the test page. - * This is only available in SPANISH_TEST_PAGE. + * + * The following options will only work when testing SELECT_TEST_PAGE_URL. + * + * @param {boolean} options.selectFrenchSection - Selects the section of French text. + * @param {boolean} options.selectEnglishSection - Selects the section of English text. + * @param {boolean} options.selectSpanishSection - Selects the section of Spanish text. + * @param {boolean} options.selectFrenchSentence - Selects a French sentence. + * @param {boolean} options.selectEnglishSentence - Selects an English sentence. + * @param {boolean} options.selectSpanishSentence - Selects a Spanish sentence. + * @param {boolean} options.openAtFrenchSection - Opens the context menu at the section of French text. + * @param {boolean} options.openAtEnglishSection - Opens the context menu at the section of English text. + * @param {boolean} options.openAtSpanishSection - Opens the context menu at the section of Spanish text. + * @param {boolean} options.openAtFrenchSentence - Opens the context menu at a French sentence. + * @param {boolean} options.openAtEnglishSentence - Opens the context menu at an English sentence. + * @param {boolean} options.openAtSpanishSentence - Opens the context menu at a Spanish sentence. + * @param {boolean} options.openAtFrenchHyperlink - Opens the context menu at a hyperlinked French text. + * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at an hyperlinked English text. + * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at a hyperlinked Spanish text. * @throws Throws an error if no valid option was provided for opening the menu. */ - static async openContextMenu( - runInPage, - { - selectFirstParagraph, - selectSpanishParagraph, - openAtFirstParagraph, - openAtSpanishParagraph, - openAtEnglishHyperlink, - openAtSpanishHyperlink, - } - ) { + static async openContextMenu(runInPage, options) { logAction(); - if (selectFirstParagraph === true) { - await runInPage(async TranslationsTest => { - const { getFirstParagraph } = TranslationsTest.getSelectors(); - const paragraph = getFirstParagraph(); - TranslationsTest.selectContentElement(paragraph); - }); - } + const maybeSelectContentFrom = async keyword => { + const conditionVariableName = `select${keyword}`; + const selectorFunctionName = `get${keyword}`; + + if (options[conditionVariableName]) { + await runInPage( + async (TranslationsTest, data) => { + const selectorFunction = + TranslationsTest.getSelectors()[data.selectorFunctionName]; + if (typeof selectorFunction === "function") { + const paragraph = selectorFunction(); + TranslationsTest.selectContentElement(paragraph); + } + }, + { selectorFunctionName } + ); + } + }; - if (selectSpanishParagraph === true) { - await runInPage(async TranslationsTest => { - const { getSpanishParagraph } = TranslationsTest.getSelectors(); - const paragraph = getSpanishParagraph(); - TranslationsTest.selectContentElement(paragraph); - }); + await maybeSelectContentFrom("FrenchSection"); + await maybeSelectContentFrom("EnglishSection"); + await maybeSelectContentFrom("SpanishSection"); + await maybeSelectContentFrom("FrenchSentence"); + await maybeSelectContentFrom("EnglishSentence"); + await maybeSelectContentFrom("SpanishSentence"); + + const maybeOpenContextMenuAt = async keyword => { + const optionVariableName = `openAt${keyword}`; + const selectorFunctionName = `get${keyword}`; + + if (options[optionVariableName]) { + await SharedTranslationsTestUtils._waitForPopupEvent( + "contentAreaContextMenu", + "popupshown", + async () => { + await runInPage( + async (TranslationsTest, data) => { + const selectorFunction = + TranslationsTest.getSelectors()[data.selectorFunctionName]; + if (typeof selectorFunction === "function") { + const element = selectorFunction(); + await TranslationsTest.rightClickContentElement(element); + } + }, + { selectorFunctionName } + ); + } + ); + } + }; + + await maybeOpenContextMenuAt("FrenchSection"); + await maybeOpenContextMenuAt("EnglishSection"); + await maybeOpenContextMenuAt("SpanishSection"); + await maybeOpenContextMenuAt("FrenchSentence"); + await maybeOpenContextMenuAt("EnglishSentence"); + await maybeOpenContextMenuAt("SpanishSentence"); + await maybeOpenContextMenuAt("FrenchHyperlink"); + await maybeOpenContextMenuAt("EnglishHyperlink"); + await maybeOpenContextMenuAt("SpanishHyperlink"); + } + + /** + * Handles language-model downloads for the SelectTranslationsPanel, ensuring that expected + * UI states match based on the resolved download state. + * + * @param {object} options - Configuration options for downloads. + * @param {function(number): Promise<void>} options.downloadHandler - The function to resolve or reject the downloads. + * @param {boolean} [options.pivotTranslation] - Whether to expect a pivot translation. + * + * @returns {Promise<void>} + */ + static async handleDownloads({ downloadHandler, pivotTranslation }) { + const { textArea } = SelectTranslationsPanel.elements; + + if (downloadHandler) { + if (textArea.style.overflow !== "hidden") { + await BrowserTestUtils.waitForMutationCondition( + textArea, + { attributes: true, attributeFilter: ["style"] }, + () => textArea.style.overflow === "hidden" + ); + } + + await SelectTranslationsTestUtils.assertPanelViewActivelyTranslating(); + await downloadHandler(pivotTranslation ? 2 : 1); } - if (openAtFirstParagraph === true) { - await SharedTranslationsTestUtils._waitForPopupEvent( - "contentAreaContextMenu", - "popupshown", - async () => { - await runInPage(async TranslationsTest => { - const { getFirstParagraph } = TranslationsTest.getSelectors(); - const paragraph = getFirstParagraph(); - await TranslationsTest.rightClickContentElement(paragraph); - }); - } + if (textArea.style.overflow === "hidden") { + await BrowserTestUtils.waitForMutationCondition( + textArea, + { attributes: true, attributeFilter: ["style"] }, + () => textArea.style.overflow === "auto" ); - return; } + } - if (openAtSpanishParagraph === true) { - await SharedTranslationsTestUtils._waitForPopupEvent( - "contentAreaContextMenu", - "popupshown", - async () => { - await runInPage(async TranslationsTest => { - const { getSpanishParagraph } = TranslationsTest.getSelectors(); - const paragraph = getSpanishParagraph(); - await TranslationsTest.rightClickContentElement(paragraph); - }); - } + /** + * Switches the selected from-language to the provided language tags + * + * @param {string[]} langTags - An array of BCP-47 language tags. + * @param {object} options - Configuration options for the language change. + * @param {boolean} options.openDropdownMenu - Determines whether the language change should be made via a dropdown menu or directly. + * + * @returns {Promise<void>} + */ + static async changeSelectedFromLanguage(langTags, options) { + const { fromMenuList, fromMenuPopup } = SelectTranslationsPanel.elements; + const { openDropdownMenu } = options; + + const switchFn = openDropdownMenu + ? SelectTranslationsTestUtils.#changeSelectedLanguageViaDropdownMenu + : SelectTranslationsTestUtils.#changeSelectedLanguageDirectly; + + await switchFn( + langTags, + { menuList: fromMenuList, menuPopup: fromMenuPopup }, + options + ); + } + + /** + * Switches the selected to-language to the provided language tag. + * + * @param {string[]} langTags - An array of BCP-47 language tags. + * @param {object} options - Options for selecting paragraphs and opening the context menu. + * @param {boolean} options.openDropdownMenu - Determines whether the language change should be made via a dropdown menu or directly. + * @param {Function} options.downloadHandler - Handler for initiating downloads post language change, if applicable. + * @param {Function} options.onChangeLanguage - Callback function to be executed after the language change. + * + * @returns {Promise<void>} + */ + static async changeSelectedToLanguage(langTags, options) { + const { toMenuList, toMenuPopup } = SelectTranslationsPanel.elements; + const { openDropdownMenu } = options; + + const switchFn = openDropdownMenu + ? SelectTranslationsTestUtils.#changeSelectedLanguageViaDropdownMenu + : SelectTranslationsTestUtils.#changeSelectedLanguageDirectly; + + await switchFn( + langTags, + { menuList: toMenuList, menuPopup: toMenuPopup }, + options + ); + } + + /** + * Directly changes the selected language to each provided language tag without using a dropdown menu. + * + * @param {string[]} langTags - An array of BCP-47 language tags for direct selection. + * @param {object} elements - Elements required for changing the selected language. + * @param {Element} elements.menuList - The menu list element where languages are directly changed. + * @param {object} options - Configuration options for language change and additional actions. + * @param {Function} options.downloadHandler - Handler for initiating downloads post language change, if applicable. + * @param {Function} options.onChangeLanguage - Callback function to be executed after the language change. + * + * @returns {Promise<void>} + */ + static async #changeSelectedLanguageDirectly(langTags, elements, options) { + const { menuList } = elements; + const { onChangeLanguage, downloadHandler } = options; + + for (const langTag of langTags) { + const menuListUpdated = BrowserTestUtils.waitForMutationCondition( + menuList, + { attributes: true, attributeFilter: ["value"] }, + () => menuList.value === langTag ); - return; + + menuList.value = langTag; + menuList.dispatchEvent(new Event("command")); + await menuListUpdated; } - if (openAtEnglishHyperlink === true) { - await SharedTranslationsTestUtils._waitForPopupEvent( - "contentAreaContextMenu", - "popupshown", - async () => { - await runInPage(async TranslationsTest => { - const { getEnglishHyperlink } = TranslationsTest.getSelectors(); - const hyperlink = getEnglishHyperlink(); - await TranslationsTest.rightClickContentElement(hyperlink); - }); - } - ); - return; + if (downloadHandler) { + menuList.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + await SelectTranslationsTestUtils.handleDownloads(options); } - if (openAtSpanishHyperlink === true) { - await SharedTranslationsTestUtils._waitForPopupEvent( - "contentAreaContextMenu", + if (onChangeLanguage) { + await onChangeLanguage(); + } + } + + /** + * Changes the selected language by opening the dropdown menu for each provided language tag. + * + * @param {string[]} langTags - An array of BCP-47 language tags for selection via dropdown. + * @param {object} elements - Elements involved in the dropdown language selection process. + * @param {Element} elements.menuList - The element that triggers the dropdown menu. + * @param {Element} elements.menuPopup - The dropdown menu element containing selectable languages. + * @param {object} options - Configuration options for language change and additional actions. + * @param {Function} options.downloadHandler - Handler for initiating downloads post language change, if applicable. + * @param {Function} options.onChangeLanguage - Callback function to be executed after the language change. + * + * @returns {Promise<void>} + */ + static async #changeSelectedLanguageViaDropdownMenu( + langTags, + elements, + options + ) { + const { menuList, menuPopup } = elements; + const { onChangeLanguage } = options; + for (const langTag of langTags) { + await SelectTranslationsTestUtils.waitForPanelPopupEvent( "popupshown", - async () => { - await runInPage(async TranslationsTest => { - const { getSpanishHyperlink } = TranslationsTest.getSelectors(); - const hyperlink = getSpanishHyperlink(); - await TranslationsTest.rightClickContentElement(hyperlink); - }); + () => click(menuList) + ); + + const menuItem = menuPopup.querySelector(`[value="${langTag}"]`); + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popuphidden", + () => { + click(menuItem); + // Synthesizing a click on the menuitem isn't closing the popup + // as a click normally would, so this tab keypress is added to + // ensure the popup closes. + EventUtils.synthesizeKey("KEY_Tab"); } ); - return; - } - throw new Error( - "openContextMenu() was not provided a declaration for which element to open the menu at." - ); + await SelectTranslationsTestUtils.handleDownloads(options); + if (onChangeLanguage) { + await onChangeLanguage(); + } + } } /** @@ -1614,36 +2094,32 @@ class SelectTranslationsTestUtils { * * @param {Function} runInPage - A content-exposed function to run within the context of the page. * @param {object} options - Options for selecting paragraphs and opening the context menu. - * @param {boolean} options.selectFirstParagraph - Selects the first paragraph before opening the context menu. - * @param {boolean} options.selectSpanishParagraph - Selects the Spanish paragraph before opening the context menu. - * This is only available in SPANISH_TEST_PAGE. - * @param {string} options.expectedTargetLanguage - The target language for translation. - * @param {boolean} options.openAtFirstParagraph - Opens the context menu at the first paragraph. - * @param {boolean} options.openAtSpanishParagraph - Opens at the Spanish paragraph. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtEnglishHyperlink - Opens at the English hyperlink. - * This is only available in SPANISH_TEST_PAGE. - * @param {boolean} options.openAtSpanishHyperlink - Opens at the Spanish hyperlink. - * This is only available in SPANISH_TEST_PAGE. - * @param {Function|null} [options.onOpenPanel=null] - An optional callback function to execute after the panel opens. - * @param {string|null} [message=null] - An optional message to log to info. + * + * The following options will only work when testing SELECT_TEST_PAGE_URL. + * + * @param {string} options.expectedFromLanguage - The expected from-language tag. + * @param {string} options.expectedToLanguage - The expected to-language tag. + * @param {boolean} options.selectFrenchSection - Selects the section of French text. + * @param {boolean} options.selectEnglishSection - Selects the section of English text. + * @param {boolean} options.selectSpanishSection - Selects the section of Spanish text. + * @param {boolean} options.selectFrenchSentence - Selects a French sentence. + * @param {boolean} options.selectEnglishSentence - Selects an English sentence. + * @param {boolean} options.selectSpanishSentence - Selects a Spanish sentence. + * @param {boolean} options.openAtFrenchSection - Opens the context menu at the section of French text. + * @param {boolean} options.openAtEnglishSection - Opens the context menu at the section of English text. + * @param {boolean} options.openAtSpanishSection - Opens the context menu at the section of Spanish text. + * @param {boolean} options.openAtFrenchSentence - Opens the context menu at a French sentence. + * @param {boolean} options.openAtEnglishSentence - Opens the context menu at an English sentence. + * @param {boolean} options.openAtSpanishSentence - Opens the context menu at a Spanish sentence. + * @param {boolean} options.openAtFrenchHyperlink - Opens the context menu at a hyperlinked French text. + * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at an hyperlinked English text. + * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at a hyperlinked Spanish text. + * @param {Function} [options.onOpenPanel] - An optional callback function to execute after the panel opens. + * @param {string|null} [message] - An optional message to log to info. * @throws Throws an error if the context menu could not be opened with the provided options. * @returns {Promise<void>} */ - static async openPanel( - runInPage, - { - selectFirstParagraph, - selectSpanishParagraph, - expectedTargetLanguage, - openAtFirstParagraph, - openAtSpanishParagraph, - openAtEnglishHyperlink, - openAtSpanishHyperlink, - onOpenPanel, - }, - message - ) { + static async openPanel(runInPage, options, message) { logAction(); if (message) { @@ -1652,15 +2128,7 @@ class SelectTranslationsTestUtils { await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( runInPage, - { - selectFirstParagraph, - selectSpanishParagraph, - expectedTargetLanguage, - openAtFirstParagraph, - openAtSpanishParagraph, - openAtEnglishHyperlink, - openAtSpanishHyperlink, - }, + options, message ); @@ -1668,9 +2136,28 @@ class SelectTranslationsTestUtils { await SelectTranslationsTestUtils.waitForPanelPopupEvent( "popupshown", - () => click(menuItem), - onOpenPanel + async () => { + click(menuItem); + await closeContextMenuIfOpen(); + }, + async () => { + const { onOpenPanel } = options; + await SelectTranslationsTestUtils.handleDownloads(options); + if (onOpenPanel) { + await onOpenPanel(); + } + } ); + + const { expectedFromLanguage, expectedToLanguage } = options; + if (expectedFromLanguage !== undefined) { + SelectTranslationsTestUtils.assertSelectedFromLanguage( + expectedFromLanguage + ); + } + if (expectedToLanguage !== undefined) { + SelectTranslationsTestUtils.assertSelectedToLanguage(expectedToLanguage); + } } /** @@ -1732,10 +2219,10 @@ class TranslationsSettingsTestUtils { translateNeverHeader: document.getElementById( "translations-settings-never-translate" ), - translateAlwaysAddButton: document.getElementById( + translateAlwaysMenuList: document.getElementById( "translations-settings-always-translate-list" ), - translateNeverAddButton: document.getElementById( + translateNeverMenuList: document.getElementById( "translations-settings-never-translate-list" ), translateNeverSiteHeader: document.getElementById( @@ -1744,12 +2231,15 @@ class TranslationsSettingsTestUtils { translateNeverSiteDesc: document.getElementById( "translations-settings-never-sites" ), - translateDownloadLanguagesHeader: document.getElementById( - "translations-settings-download-languages" - ), + translateDownloadLanguagesHeader: document + .getElementById("translations-settings-download-section") + .querySelector("h2"), translateDownloadLanguagesLearnMore: document.getElementById( "download-languages-learn-more" ), + translateDownloadLanguagesList: document.getElementById( + "translations-settings-download-section" + ), }; return elements; diff --git a/browser/components/uitour/UITour-lib.js b/browser/components/uitour/UITour-lib.js index 0df3059425..a83ec95200 100644 --- a/browser/components/uitour/UITour-lib.js +++ b/browser/components/uitour/UITour-lib.js @@ -9,7 +9,7 @@ if (typeof Mozilla == "undefined") { var Mozilla = {}; } -(function ($) { +(function () { "use strict"; // create namespace diff --git a/browser/components/uitour/UITour.sys.mjs b/browser/components/uitour/UITour.sys.mjs index fef68a5a95..920815fec5 100644 --- a/browser/components/uitour/UITour.sys.mjs +++ b/browser/components/uitour/UITour.sys.mjs @@ -339,7 +339,7 @@ export var UITour = { let callback = buttonData.callbackID; let button = { label: buttonData.label, - callback: event => { + callback: () => { this.sendPageCallback(browser, callback); }, }; @@ -694,7 +694,7 @@ export var UITour = { } }, - observe(aSubject, aTopic, aData) { + observe(aSubject, aTopic) { lazy.log.debug("observe: aTopic =", aTopic); switch (aTopic) { // The browser message manager is disconnected when the <browser> is @@ -918,7 +918,7 @@ export var UITour = { ); }, - getTarget(aWindow, aTargetName, aSticky = false) { + getTarget(aWindow, aTargetName) { lazy.log.debug("getTarget:", aTargetName); if (typeof aTargetName != "string" || !aTargetName) { lazy.log.warn("getTarget: Invalid target name specified"); @@ -1280,7 +1280,7 @@ export var UITour = { tooltipButtons.hidden = !aButtons.length; let tooltipClose = document.getElementById("UITourTooltipClose"); - let closeButtonCallback = event => { + let closeButtonCallback = () => { this.hideInfo(document.defaultView); if (aOptions && aOptions.closeButtonCallback) { aOptions.closeButtonCallback(); @@ -1301,7 +1301,7 @@ export var UITour = { tooltip.addEventListener( "popuphiding", - function (event) { + function () { tooltipClose.removeEventListener("command", closeButtonCallback); if (aOptions.targetCallback && aAnchor.removeTargetListener) { aAnchor.removeTargetListener(document, targetCallback); diff --git a/browser/components/uitour/test/browser_UITour.js b/browser/components/uitour/test/browser_UITour.js index d9e517af1f..c961ab3c0d 100644 --- a/browser/components/uitour/test/browser_UITour.js +++ b/browser/components/uitour/test/browser_UITour.js @@ -323,7 +323,7 @@ var tests = [ () => { highlight.addEventListener( "animationstart", - function (aEvent) { + function () { ok( true, "Animation occurred again even though the effect was the same" diff --git a/browser/components/uitour/test/browser_UITour3.js b/browser/components/uitour/test/browser_UITour3.js index 526994f420..2820fbd020 100644 --- a/browser/components/uitour/test/browser_UITour3.js +++ b/browser/components/uitour/test/browser_UITour3.js @@ -81,7 +81,7 @@ add_UITour_task(async function test_info_buttons_1() { ); is( buttons.children[0].getAttribute("image"), - "", + null, "Text should have no image" ); is(buttons.children[0].className, "", "Text should have no class"); @@ -94,7 +94,7 @@ add_UITour_task(async function test_info_buttons_1() { ); is( buttons.children[1].getAttribute("image"), - "", + null, "Link should have no image" ); is(buttons.children[1].className, "button-link", "Check link class"); @@ -107,7 +107,7 @@ add_UITour_task(async function test_info_buttons_1() { ); is( buttons.children[2].getAttribute("image"), - "", + null, "First button should have no image" ); is(buttons.children[2].className, "", "Button 1 should have no class"); @@ -173,7 +173,7 @@ add_UITour_task(async function test_info_buttons_2() { ); is( buttons.children[1].getAttribute("image"), - "", + null, "Link should have no image" ); ok( @@ -188,7 +188,7 @@ add_UITour_task(async function test_info_buttons_2() { ); is( buttons.children[2].getAttribute("image"), - "", + null, "First button should have no image" ); diff --git a/browser/components/uitour/test/browser_UITour_defaultBrowser.js b/browser/components/uitour/test/browser_UITour_defaultBrowser.js index 721ab2f8c0..a8572e49ab 100644 --- a/browser/components/uitour/test/browser_UITour_defaultBrowser.js +++ b/browser/components/uitour/test/browser_UITour_defaultBrowser.js @@ -12,10 +12,10 @@ Services.scriptloader.loadSubScript( function MockShellService() {} MockShellService.prototype = { QueryInterface: ChromeUtils.generateQI(["nsIShellService"]), - isDefaultBrowser(aStartupCheck, aForAllTypes) { + isDefaultBrowser() { return false; }, - setDefaultBrowser(aForAllUsers) { + setDefaultBrowser() { setDefaultBrowserCalled = true; }, shouldCheckDefaultBrowser: false, @@ -26,7 +26,7 @@ MockShellService.prototype = { BACKGROUND_FILL: 4, BACKGROUND_FIT: 5, BACKGROUND_SPAN: 6, - setDesktopBackground(aElement, aPosition) {}, + setDesktopBackground() {}, desktopBackgroundColor: 0, }; diff --git a/browser/components/uitour/test/browser_UITour_modalDialog.js b/browser/components/uitour/test/browser_UITour_modalDialog.js index a711ee2f2e..5d1e0a5303 100644 --- a/browser/components/uitour/test/browser_UITour_modalDialog.js +++ b/browser/components/uitour/test/browser_UITour_modalDialog.js @@ -39,7 +39,7 @@ var observer = SpecialPowers.wrapCallbackObject({ return this; }, - observe(subject, topic, data) { + observe() { var doc = getDialogDoc(); if (doc) { handleDialog(doc); diff --git a/browser/components/uitour/test/head.js b/browser/components/uitour/test/head.js index 07b941ba1c..6c7ca00d6e 100644 --- a/browser/components/uitour/test/head.js +++ b/browser/components/uitour/test/head.js @@ -194,14 +194,7 @@ function hideInfoPromise(...args) { * function name to call to generate the buttons/options instead of the * buttons/options themselves. This makes the signature differ from the content one. */ -function showInfoPromise( - target, - title, - text, - icon, - buttonsFunctionName, - optionsFunctionName -) { +function showInfoPromise() { let popup = document.getElementById("UITourTooltip"); let shownPromise = promisePanelElementShown(window, popup); return SpecialPowers.spawn(gTestTab.linkedBrowser, [[...arguments]], args => { @@ -271,7 +264,7 @@ function promisePanelElementEvent(win, aPanel, aEvent) { reject(aEvent + " event did not happen within 5 seconds."); }, 5000); - function onPanelEvent(e) { + function onPanelEvent() { aPanel.removeEventListener(aEvent, onPanelEvent); win.clearTimeout(timeoutId); // Wait one tick to let UITour.sys.mjs process the event as well. @@ -321,7 +314,7 @@ async function loadUITourTestPage(callback, host = "https://example.org/") { // return a function which calls the method of the same name on // contentWin.Mozilla.UITour in a ContentTask. let UITourHandler = { - get(target, prop, receiver) { + get(target, prop) { return (...args) => { let browser = gTestTab.linkedBrowser; // We need to proxy any callback functions using messages: diff --git a/browser/components/urlbar/.eslintrc.js b/browser/components/urlbar/.eslintrc.js index 8ead689bcc..aac2436d20 100644 --- a/browser/components/urlbar/.eslintrc.js +++ b/browser/components/urlbar/.eslintrc.js @@ -5,8 +5,6 @@ "use strict"; module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], - rules: { "mozilla/var-only-at-top-level": "error", "no-unused-expressions": "error", diff --git a/browser/components/urlbar/UrlbarController.sys.mjs b/browser/components/urlbar/UrlbarController.sys.mjs index 9bfc3a645d..7e4d0ff1c5 100644 --- a/browser/components/urlbar/UrlbarController.sys.mjs +++ b/browser/components/urlbar/UrlbarController.sys.mjs @@ -133,6 +133,7 @@ export class UrlbarController { // notifications related to the previous query. this.notify(NOTIFICATIONS.QUERY_STARTED, queryContext); await this.manager.startQuery(queryContext, this); + // If the query has been cancelled, onQueryFinished was notified already. // Note this._lastQueryContextWrapper may have changed in the meanwhile. if ( @@ -144,6 +145,16 @@ export class UrlbarController { this.manager.cancelQuery(queryContext); this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext); } + + // Record a potential exposure if the current search string matches one of + // the registered keywords. + if (!queryContext.isPrivate) { + let searchStr = queryContext.trimmedLowerCaseSearchString; + if (lazy.UrlbarPrefs.get("potentialExposureKeywords").has(searchStr)) { + this.engagementEvent.addPotentialExposure(searchStr); + } + } + return queryContext; } @@ -335,7 +346,7 @@ export class UrlbarController { } event.preventDefault(); break; - case KeyEvent.DOM_VK_TAB: + case KeyEvent.DOM_VK_TAB: { // It's always possible to tab through results when the urlbar was // focused with the mouse or has a search string, or when the view // already has a selection. @@ -368,6 +379,7 @@ export class UrlbarController { event.preventDefault(); } break; + } case KeyEvent.DOM_VK_PAGE_DOWN: case KeyEvent.DOM_VK_PAGE_UP: if (event.ctrlKey) { @@ -592,8 +604,8 @@ export class UrlbarController { /** * Triggers a "dismiss" engagement for the selected result if one is selected * and it's not the heuristic. Providers that can respond to dismissals of - * their results should implement `onEngagement()`, handle the dismissal, and - * call `controller.removeResult()`. + * their results should implement `onLegacyEngagement()`, handle the + * dismissal, and call `controller.removeResult()`. * * @param {Event} event * The event that triggered dismissal. @@ -783,13 +795,6 @@ class TelemetryEvent { interactionType: this._getStartInteractionType(event, searchString), searchString, }; - - this._controller.manager.notifyEngagementChange( - "start", - queryContext, - {}, - this._controller - ); } /** @@ -821,17 +826,31 @@ class TelemetryEvent { * @param {DOMElement} [details.element] The picked view element. */ record(event, details) { + // Prevent re-entering `record()`. This can happen because + // `#internalRecord()` will notify an engagement to the provider, that may + // execute an action blurring the input field. Then both an engagement + // and an abandonment would be recorded for the same session. + // Nulling out `_startEventInfo` doesn't save us in this case, because it + // happens after `#internalRecord()`, and `isSessionOngoing` must be + // calculated inside it. + if (this.#handlingRecord) { + return; + } + // This should never throw, or it may break the urlbar. try { - this._internalRecord(event, details); + this.#handlingRecord = true; + this.#internalRecord(event, details); } catch (ex) { console.error("Could not record event: ", ex); } finally { + this.#handlingRecord = false; + // Reset the start event info except for engagements that do not end the // search session. In that case, the view stays open and further // engagements are possible and should be recorded when they occur. // (`details.isSessionOngoing` is not a param; rather, it's set by - // `_internalRecord()`.) + // `#internalRecord()`.) if (!details.isSessionOngoing) { this._startEventInfo = null; this._discarded = false; @@ -839,19 +858,10 @@ class TelemetryEvent { } } - _internalRecord(event, details) { + #internalRecord(event, details) { const startEventInfo = this._startEventInfo; if (!this._category || !startEventInfo) { - if (this._discarded && this._category && details?.selType !== "dismiss") { - let { queryContext } = this._controller._lastQueryContextWrapper || {}; - this._controller.manager.notifyEngagementChange( - "discard", - queryContext, - {}, - this._controller - ); - } return; } if ( @@ -938,6 +948,10 @@ class TelemetryEvent { } ); + if (!details.isSessionOngoing) { + this.#recordEndOfSessionTelemetry(details.searchString); + } + if (skipLegacyTelemetry) { this._controller.manager.notifyEngagementChange( method, @@ -1091,23 +1105,6 @@ class TelemetryEvent { return; } - // First check to see if we can record an exposure event - if (method === "abandonment" || method === "engagement") { - if (this.#exposureResultTypes.size) { - let exposure = { - results: [...this.#exposureResultTypes].sort().join(","), - }; - this._controller.logger.debug( - `exposure event: ${JSON.stringify(exposure)}` - ); - Glean.urlbar.exposure.record(exposure); - } - - // reset the provider list on the controller - this.#exposureResultTypes.clear(); - this.#tentativeExposureResultTypes.clear(); - } - this._controller.logger.info( `${method} event: ${JSON.stringify(eventInfo)}` ); @@ -1115,6 +1112,38 @@ class TelemetryEvent { Glean.urlbar[method].record(eventInfo); } + #recordEndOfSessionTelemetry(searchString) { + // exposures + if (this.#exposureResultTypes.size) { + let exposure = { + results: [...this.#exposureResultTypes].sort().join(","), + }; + this._controller.logger.debug( + `exposure event: ${JSON.stringify(exposure)}` + ); + Glean.urlbar.exposure.record(exposure); + this.#exposureResultTypes.clear(); + } + this.#tentativeExposureResultTypes.clear(); + + // potential exposures + if (this.#potentialExposureKeywords.size) { + let normalizedSearchString = searchString.trim().toLowerCase(); + for (let keyword of this.#potentialExposureKeywords) { + let data = { + keyword, + terminal: keyword == normalizedSearchString, + }; + this._controller.logger.debug( + `potential_exposure event: ${JSON.stringify(data)}` + ); + Glean.urlbar.potentialExposure.record(data); + } + GleanPings.urlbarPotentialExposure.submit(); + this.#potentialExposureKeywords.clear(); + } + } + /** * Registers an exposure for a result in the current urlbar session. All * exposures that are added during a session are recorded in an exposure event @@ -1164,6 +1193,16 @@ class TelemetryEvent { this.#tentativeExposureResultTypes.clear(); } + /** + * Registers a potential exposure in the current urlbar session. + * + * @param {string} keyword + * The keyword that was matched. + */ + addPotentialExposure(keyword) { + this.#potentialExposureKeywords.add(keyword); + } + #getInteractionType( method, startEventInfo, @@ -1350,8 +1389,12 @@ class TelemetryEvent { } } + // Used to avoid re-entering `record()`. + #handlingRecord = false; + #previousSearchWordsSet = null; #exposureResultTypes = new Set(); #tentativeExposureResultTypes = new Set(); + #potentialExposureKeywords = new Set(); } diff --git a/browser/components/urlbar/UrlbarInput.sys.mjs b/browser/components/urlbar/UrlbarInput.sys.mjs index 96fc7b9301..a96e862cff 100644 --- a/browser/components/urlbar/UrlbarInput.sys.mjs +++ b/browser/components/urlbar/UrlbarInput.sys.mjs @@ -1364,7 +1364,7 @@ export class UrlbarInput { // The value setter clobbers the actiontype attribute, so we need this // helper to restore it afterwards. const setValueAndRestoreActionType = (value, allowTrim) => { - this._setValue(value, allowTrim); + this._setValue(value, { allowTrim }); switch (result.type) { case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: @@ -1555,7 +1555,7 @@ export class UrlbarInput { !this.value.endsWith(" ") ) { this._autofillPlaceholder = null; - this._setValue(this.window.gBrowser.userTypedValue, false); + this._setValue(this.window.gBrowser.userTypedValue); } return false; @@ -1940,7 +1940,7 @@ export class UrlbarInput { } set value(val) { - this._setValue(val, true); + this._setValue(val, { allowTrim: true }); } get lastSearchString() { @@ -2107,7 +2107,7 @@ export class UrlbarInput { this.searchMode = searchMode; let value = result.payload.query?.trimStart() || ""; - this._setValue(value, false); + this._setValue(value); if (startQuery) { this.startQuery({ allowAutofill: false }); @@ -2253,10 +2253,6 @@ export class UrlbarInput { "--urlbar-height", px(getBoundsWithoutFlushing(this.textbox).height) ); - this.textbox.style.setProperty( - "--urlbar-toolbar-height", - px(getBoundsWithoutFlushing(this._toolbar).height) - ); this.setAttribute("breakout", "true"); this.textbox.parentNode.setAttribute("breakout", "true"); @@ -2266,7 +2262,16 @@ export class UrlbarInput { }); } - _setValue(val, allowTrim) { + /** + * Sets the input field value. + * + * @param {string} val The new value to set. + * @param {object} [options] Options for setting. + * @param {boolean} [options.allowTrim] Whether the value can be trimmed. + * + * @returns {string} The set value. + */ + _setValue(val, { allowTrim = false } = {}) { // Don't expose internal about:reader URLs to the user. let originalUrl = lazy.ReaderMode.getOriginalUrlObjectForDisplay(val); if (originalUrl) { @@ -2730,7 +2735,7 @@ export class UrlbarInput { }) { // The autofilled value may be a URL that includes a scheme at the // beginning. Do not allow it to be trimmed. - this._setValue(value, false); + this._setValue(value); this.inputField.setSelectionRange(selectionStart, selectionEnd); this._autofillPlaceholder = { value, @@ -3152,8 +3157,8 @@ export class UrlbarInput { this.select(); this.window.goDoCommand("cmd_paste"); this.setResultForCurrentValue(null); - this.controller.clearLastQueryContextCache(); this.handleCommand(); + this.controller.clearLastQueryContextCache(); this._suppressStartQuery = false; }); @@ -3504,7 +3509,7 @@ export class UrlbarInput { } if (untrim) { this._focusUntrimmedValue = this._untrimmedValue; - this._setValue(this._focusUntrimmedValue, false); + this._setValue(this._focusUntrimmedValue); } } diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs index 022d0b1c7c..cd8a6b0f4c 100644 --- a/browser/components/urlbar/UrlbarPrefs.sys.mjs +++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs @@ -65,7 +65,7 @@ const PREF_URLBAR_DEFAULTS = new Map([ ["autoFill.stddevMultiplier", [0.0, "float"]], // Feature gate pref for clipboard suggestions in the urlbar. - ["clipboard.featureGate", true], + ["clipboard.featureGate", false], // Whether to show a link for using the search functionality provided by the // active view if the the view utilizes OpenSearch. @@ -1507,12 +1507,28 @@ class Preferences { return this.shouldHandOffToSearchModePrefs.some( prefName => !this.get(prefName) ); - case "autoFillAdaptiveHistoryUseCountThreshold": + case "autoFillAdaptiveHistoryUseCountThreshold": { const nimbusValue = this._nimbus.autoFillAdaptiveHistoryUseCountThreshold; return nimbusValue === undefined ? this.get("autoFill.adaptiveHistory.useCountThreshold") : parseFloat(nimbusValue); + } + case "potentialExposureKeywords": { + // Get the keywords array from Nimbus or prefs and convert it to a Set. + // If the value comes from Nimbus, it will already be an array. If it + // comes from prefs, it should be a stringified array. + let value = this._readPref(pref); + if (typeof value == "string") { + try { + value = JSON.parse(value); + } catch (e) {} + } + if (!Array.isArray(value)) { + value = null; + } + return new Set(value); + } } return this._readPref(pref); } diff --git a/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs b/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs index 62f85b2348..be607a80d5 100644 --- a/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs @@ -49,7 +49,7 @@ class ProviderAboutPages extends UrlbarProvider { * @returns {boolean} Whether this provider should be invoked for the search. */ isActive(queryContext) { - return queryContext.trimmedSearchString.toLowerCase().startsWith("about:"); + return queryContext.trimmedLowerCaseSearchString.startsWith("about:"); } /** @@ -61,7 +61,7 @@ class ProviderAboutPages extends UrlbarProvider { * result. A UrlbarResult should be passed to it. */ startQuery(queryContext, addCallback) { - let searchString = queryContext.trimmedSearchString.toLowerCase(); + let searchString = queryContext.trimmedLowerCaseSearchString; for (const aboutUrl of lazy.AboutPagesUtils.visibleAboutUrls) { if (aboutUrl.startsWith(searchString)) { let result = new lazy.UrlbarResult( diff --git a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs index 32e605206e..7470df0fea 100644 --- a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs @@ -653,7 +653,7 @@ class ProviderAutofill extends UrlbarProvider { queryType: QUERYTYPE.AUTOFILL_ADAPTIVE, // `fullSearchString` is the value the user typed including a prefix if // they typed one. `searchString` has been stripped of the prefix. - fullSearchString: queryContext.searchString.toLowerCase(), + fullSearchString: queryContext.lowerCaseSearchString, searchString: this._searchString, strippedPrefix: this._strippedPrefix, useCountThreshold: lazy.UrlbarPrefs.get( diff --git a/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs b/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs index a55531167c..3f0ffed299 100644 --- a/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs @@ -157,7 +157,7 @@ class ProviderCalculator extends UrlbarProvider { return viewUpdate; } - onEngagement(state, queryContext, details) { + onLegacyEngagement(state, queryContext, details) { let { result } = details; if (result?.providerName == this.name) { lazy.ClipboardHelper.copyString(result.payload.value); diff --git a/browser/components/urlbar/UrlbarProviderClipboard.sys.mjs b/browser/components/urlbar/UrlbarProviderClipboard.sys.mjs index 5337e610cc..1dc5bb9b86 100644 --- a/browser/components/urlbar/UrlbarProviderClipboard.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderClipboard.sys.mjs @@ -143,10 +143,7 @@ class ProviderClipboard extends UrlbarProvider { addCallback(this, result); } - onEngagement(state, queryContext, details, controller) { - if (!["engagement", "abandonment"].includes(state)) { - return; - } + onLegacyEngagement(state, queryContext, details, controller) { const visibleResults = controller.view?.visibleResults ?? []; for (const result of visibleResults) { if ( diff --git a/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs index 63c94ee8f3..5714f11e72 100644 --- a/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs @@ -246,7 +246,7 @@ class ProviderContextualSearch extends UrlbarProvider { }; } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName == this.name) { this.#pickResult(result, controller.browserWindow); diff --git a/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs b/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs index f929a1c003..17b6a4c9b0 100644 --- a/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs @@ -200,7 +200,7 @@ class ProviderInputHistory extends UrlbarProvider { } } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName != this.name) { return; @@ -236,7 +236,7 @@ class ProviderInputHistory extends UrlbarProvider { SQL_ADAPTIVE_QUERY, { parent: lazy.PlacesUtils.tagsFolderId, - search_string: queryContext.searchString.toLowerCase(), + search_string: queryContext.lowerCaseSearchString, matchBehavior: Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE, searchBehavior: lazy.UrlbarPrefs.get("defaultBehavior"), userContextId: lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") diff --git a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs index 08b4ea36b7..68b9c1665d 100644 --- a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs @@ -703,7 +703,7 @@ class ProviderInterventions extends UrlbarProvider { } } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; // `selType` is "tip" when the tip's main button is picked. Ignore clicks on @@ -714,10 +714,8 @@ class ProviderInterventions extends UrlbarProvider { this.#pickResult(result, controller.browserWindow); } - if (["engagement", "abandonment"].includes(state)) { - for (let tip of this.tipsShownInCurrentEngagement) { - Services.telemetry.keyedScalarAdd("urlbar.tips", `${tip}-shown`, 1); - } + for (let tip of this.tipsShownInCurrentEngagement) { + Services.telemetry.keyedScalarAdd("urlbar.tips", `${tip}-shown`, 1); } this.tipsShownInCurrentEngagement.clear(); } diff --git a/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs b/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs index 351e8ff60b..362f683027 100644 --- a/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs @@ -178,7 +178,7 @@ class ProviderOmnibox extends UrlbarProvider { ); } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName != this.name) { return; diff --git a/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs b/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs index 650acd1730..c94ebee80a 100644 --- a/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs @@ -1517,7 +1517,7 @@ class ProviderPlaces extends UrlbarProvider { search.notifyResult(false); } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName != this.name) { return; diff --git a/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs index f199b6b892..29370cbaaf 100644 --- a/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs @@ -95,7 +95,7 @@ class ProviderQuickActions extends UrlbarProvider { */ async startQuery(queryContext, addCallback) { await lazy.QuickActionsLoaderDefault.ensureLoaded(); - let input = queryContext.trimmedSearchString.toLowerCase(); + let input = queryContext.trimmedLowerCaseSearchString; if ( !queryContext.searchMode && @@ -241,7 +241,7 @@ class ProviderQuickActions extends UrlbarProvider { } } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { // Ignore engagements on other results that didn't end the session. if (details.result?.providerName != this.name && details.isSessionOngoing) { return; diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs index 78e254616e..fbc8cc8c3f 100644 --- a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs @@ -229,7 +229,7 @@ class ProviderQuickSuggest extends UrlbarProvider { } } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { // Ignore engagements on other results that didn't end the session. if (details.result?.providerName != this.name && details.isSessionOngoing) { return; @@ -237,7 +237,7 @@ class ProviderQuickSuggest extends UrlbarProvider { // Reset the Merino session ID when a session ends. By design for the user's // privacy, we don't keep it around between engagements. - if (state != "start" && !details.isSessionOngoing) { + if (!details.isSessionOngoing) { this.#merino?.resetSession(); } @@ -486,8 +486,8 @@ class ProviderQuickSuggest extends UrlbarProvider { * end of the engagement or that was dismissed. Null if no quick suggest * result was present. * @param {object} details - * The `details` object that was passed to `onEngagement()`. It must look - * like this: `{ selType, selIndex }` + * The `details` object that was passed to `onLegacyEngagement()`. It must + * look like this: `{ selType, selIndex }` */ #recordEngagement(queryContext, result, details) { let resultSelType = ""; @@ -781,8 +781,8 @@ class ProviderQuickSuggest extends UrlbarProvider { * True if the main part of the result's row was clicked; false if a button * like help or dismiss was clicked or if no part of the row was clicked. * @param {object} options.details - * The `details` object that was passed to `onEngagement()`. It must look - * like this: `{ selType, selIndex }` + * The `details` object that was passed to `onLegacyEngagement()`. It must + * look like this: `{ selType, selIndex }` */ #recordNavSuggestionTelemetry({ queryContext, diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggestContextualOptIn.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggestContextualOptIn.sys.mjs index 48006d09c0..b3c322ffa1 100644 --- a/browser/components/urlbar/UrlbarProviderQuickSuggestContextualOptIn.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderQuickSuggestContextualOptIn.sys.mjs @@ -188,7 +188,7 @@ class ProviderQuickSuggestContextualOptIn extends UrlbarProvider { row.ownerGlobal.A11yUtils.announce({ raw: alertText }); } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName != this.name) { return; diff --git a/browser/components/urlbar/UrlbarProviderRecentSearches.sys.mjs b/browser/components/urlbar/UrlbarProviderRecentSearches.sys.mjs index ceeba729d4..1565013440 100644 --- a/browser/components/urlbar/UrlbarProviderRecentSearches.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderRecentSearches.sys.mjs @@ -63,7 +63,7 @@ class ProviderRecentSearches extends UrlbarProvider { return 1; } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName != this.name) { return; diff --git a/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs b/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs index 8cb3532d94..e3d13feb56 100644 --- a/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs @@ -352,7 +352,7 @@ class ProviderSearchSuggestions extends UrlbarProvider { return undefined; } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { let { result } = details; if (result?.providerName != this.name) { return; diff --git a/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs b/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs index b19528619c..a7a23a3228 100644 --- a/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs @@ -273,7 +273,7 @@ class ProviderSearchTips extends UrlbarProvider { lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, MAX_SHOWN_COUNT); } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { // Ignore engagements on other results that didn't end the session. let { result } = details; if (result?.providerName != this.name && details.isSessionOngoing) { diff --git a/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs index 9aabef3d19..0cce6481b1 100644 --- a/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs @@ -194,7 +194,7 @@ class ProviderTabToSearch extends UrlbarProvider { * Called when a result from the provider is selected. "Selected" refers to * the user highlighing the result with the arrow keys/Tab, before it is * picked. onSelection is also called when a user clicks a result. In the - * event of a click, onSelection is called just before onEngagement. + * event of a click, onSelection is called just before onLegacyEngagement. * * @param {UrlbarResult} result * The result that was selected. @@ -226,7 +226,7 @@ class ProviderTabToSearch extends UrlbarProvider { } } - onEngagement(state, queryContext, details) { + onLegacyEngagement(state, queryContext, details) { let { result, element } = details; if ( result?.providerName == this.name && diff --git a/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs b/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs index b3a91bcbe4..db9e8df382 100644 --- a/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs @@ -173,7 +173,7 @@ class ProviderTokenAliasEngines extends UrlbarProvider { } async _getAutofillResult(queryContext) { - let lowerCaseSearchString = queryContext.searchString.toLowerCase(); + let { lowerCaseSearchString } = queryContext; // The user is typing a specific engine. We should show a heuristic result. for (let { engine, tokenAliases } of this._engines) { diff --git a/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs b/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs index e9d968f20f..a046de37d4 100644 --- a/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs @@ -193,7 +193,7 @@ class ProviderTopSites extends UrlbarProvider { return site; }); - // Store Sponsored Top Sites so we can use it in `onEngagement` + // Store Sponsored Top Sites so we can use it in `onLegacyEngagement` if (sponsoredSites.length) { this.sponsoredSites = sponsoredSites; } @@ -333,12 +333,8 @@ class ProviderTopSites extends UrlbarProvider { } } - onEngagement(state, queryContext) { - if ( - !queryContext.isPrivate && - this.sponsoredSites && - ["engagement", "abandonment"].includes(state) - ) { + onLegacyEngagement(state, queryContext) { + if (!queryContext.isPrivate && this.sponsoredSites) { for (let site of this.sponsoredSites) { Services.telemetry.keyedScalarAdd( SCALAR_CATEGORY_TOPSITES, diff --git a/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs b/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs index 98c4d025e4..a5ad28d2aa 100644 --- a/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs @@ -169,7 +169,7 @@ class ProviderUnitConversion extends UrlbarProvider { addCallback(this, result); } - onEngagement(state, queryContext, details) { + onLegacyEngagement(state, queryContext, details) { let { result, element } = details; if (result?.providerName == this.name) { const { textContent } = element.querySelector( diff --git a/browser/components/urlbar/UrlbarProviderWeather.sys.mjs b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs index 24342fecab..8e9b6b8f3e 100644 --- a/browser/components/urlbar/UrlbarProviderWeather.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs @@ -115,7 +115,7 @@ class ProviderWeather extends UrlbarProvider { return false; } - return keywords.has(queryContext.searchString.trim().toLocaleLowerCase()); + return keywords.has(queryContext.trimmedLowerCaseSearchString); } /** @@ -163,7 +163,7 @@ class ProviderWeather extends UrlbarProvider { return lazy.QuickSuggest.weather.getViewUpdate(result); } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { // Ignore engagements on other results that didn't end the session. if (details.result?.providerName != this.name && details.isSessionOngoing) { return; @@ -243,7 +243,7 @@ class ProviderWeather extends UrlbarProvider { * A non-empty string means the user picked the weather row or some part of * it, and both impression and click telemetry will be recorded. The * non-empty-string values come from the `details.selType` passed in to - * `onEngagement()`; see `TelemetryEvent.typeFromElement()`. + * `onLegacyEngagement()`; see `TelemetryEvent.typeFromElement()`. */ #recordEngagementTelemetry(result, isPrivate, selType) { // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the diff --git a/browser/components/urlbar/UrlbarProvidersManager.sys.mjs b/browser/components/urlbar/UrlbarProvidersManager.sys.mjs index 609b0735e1..ac70e03e1b 100644 --- a/browser/components/urlbar/UrlbarProvidersManager.sys.mjs +++ b/browser/components/urlbar/UrlbarProvidersManager.sys.mjs @@ -334,11 +334,11 @@ class ProvidersManager { /** * Notifies all providers when the user starts and ends an engagement with the - * urlbar. For details on parameters, see UrlbarProvider.onEngagement(). + * urlbar. For details on parameters, see + * UrlbarProvider.onLegacyEngagement(). * * @param {string} state - * The state of the engagement, one of: start, engagement, abandonment, - * discard + * The state of the engagement, one of: engagement, abandonment * @param {UrlbarQueryContext} queryContext * The engagement's query context, if available. * @param {object} details @@ -349,7 +349,7 @@ class ProvidersManager { notifyEngagementChange(state, queryContext, details = {}, controller) { for (let provider of this.providers) { provider.tryMethod( - "onEngagement", + "onLegacyEngagement", state, queryContext, details, diff --git a/browser/components/urlbar/UrlbarUtils.sys.mjs b/browser/components/urlbar/UrlbarUtils.sys.mjs index 2bbb5d1ab0..9fca8426a3 100644 --- a/browser/components/urlbar/UrlbarUtils.sys.mjs +++ b/browser/components/urlbar/UrlbarUtils.sys.mjs @@ -854,7 +854,7 @@ export var UrlbarUtils = { * @returns {string} The modified paste data. */ stripUnsafeProtocolOnPaste(pasteData) { - while (true) { + for (;;) { let scheme = ""; try { scheme = Services.io.extractScheme(pasteData); @@ -1831,6 +1831,9 @@ UrlbarUtils.RESULT_PAYLOAD_SCHEMA = { isBlockable: { type: "boolean", }, + isManageable: { + type: "boolean", + }, isPinned: { type: "boolean", }, @@ -2175,6 +2178,8 @@ export class UrlbarQueryContext { this.pendingHeuristicProviders = new Set(); this.deferUserSelectionProviders = new Set(); this.trimmedSearchString = this.searchString.trim(); + this.lowerCaseSearchString = this.searchString.toLowerCase(); + this.trimmedLowerCaseSearchString = this.trimmedSearchString.toLowerCase(); this.userContextId = lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( options.userContextId, @@ -2431,21 +2436,12 @@ export class UrlbarProvider { * @param {string} _state * The state of the engagement, one of the following strings: * - * start - * A new query has started in the urlbar. * engagement * The user picked a result in the urlbar or used paste-and-go. * abandonment * The urlbar was blurred (i.e., lost focus). - * discard - * This doesn't correspond to a user action, but it means that the - * urlbar has discarded the engagement for some reason, and the - * `onEngagement` implementation should ignore it. - * * @param {UrlbarQueryContext} _queryContext - * The engagement's query context. This is *not* guaranteed to be defined - * when `state` is "start". It will always be defined for "engagement" and - * "abandonment". + * The engagement's query context. * @param {object} _details * This object is non-empty only when `state` is "engagement" or * "abandonment", and it describes the search string and engaged result. @@ -2479,7 +2475,7 @@ export class UrlbarProvider { * @param {UrlbarController} _controller * The associated controller. */ - onEngagement(_state, _queryContext, _details, _controller) {} + onLegacyEngagement(_state, _queryContext, _details, _controller) {} /** * Called before a result from the provider is selected. See `onSelection` @@ -2497,8 +2493,8 @@ export class UrlbarProvider { * Called when a result from the provider is selected. "Selected" refers to * the user highlighing the result with the arrow keys/Tab, before it is * picked. onSelection is also called when a user clicks a result. In the - * event of a click, onSelection is called just before onEngagement. Note that - * this is called when heuristic results are pre-selected. + * event of a click, onSelection is called just before onLegacyEngagement. + * Note that this is called when heuristic results are pre-selected. * * @param {UrlbarResult} _result * The result that was selected. @@ -2581,8 +2577,8 @@ export class UrlbarProvider { /** * Gets the list of commands that should be shown in the result menu for a * given result from the provider. All commands returned by this method should - * be handled by implementing `onEngagement()` with the possible exception of - * commands automatically handled by the urlbar, like "help". + * be handled by implementing `onLegacyEngagement()` with the possible + * exception of commands automatically handled by the urlbar, like "help". * * @param {UrlbarResult} _result * The menu will be shown for this result. @@ -2594,8 +2590,8 @@ export class UrlbarProvider { * {string} name * The name of the command. Must be specified unless `children` is * present. When a command is picked, its name will be passed as - * `details.selType` to `onEngagement()`. The special name "separator" - * will create a menu separator. + * `details.selType` to `onLegacyEngagement()`. The special name + * "separator" will create a menu separator. * {object} l10n * An l10n object for the command's label: `{ id, args }` * Must be specified unless `name` is "separator". diff --git a/browser/components/urlbar/UrlbarView.sys.mjs b/browser/components/urlbar/UrlbarView.sys.mjs index b5fe1e1955..3d6ea46781 100644 --- a/browser/components/urlbar/UrlbarView.sys.mjs +++ b/browser/components/urlbar/UrlbarView.sys.mjs @@ -48,6 +48,7 @@ const ZERO_PREFIX_SCALAR_EXPOSURE = "urlbar.zeroprefix.exposure"; const RESULT_MENU_COMMANDS = { DISMISS: "dismiss", HELP: "help", + MANAGE: "manage", }; const getBoundsWithoutFlushing = element => @@ -3139,6 +3140,15 @@ export class UrlbarView { }, }); } + if (result.payload.isManageable) { + commands.push({ + name: RESULT_MENU_COMMANDS.MANAGE, + l10n: { + id: "urlbar-result-menu-manage-firefox-suggest", + }, + }); + } + let rv = commands.length ? commands : null; this.#resultMenuCommands.set(result, rv); return rv; diff --git a/browser/components/urlbar/docs/dynamic-result-types.rst b/browser/components/urlbar/docs/dynamic-result-types.rst index f72c5e4a13..2c81c1656f 100644 --- a/browser/components/urlbar/docs/dynamic-result-types.rst +++ b/browser/components/urlbar/docs/dynamic-result-types.rst @@ -152,8 +152,8 @@ aren't relevant to dynamic result types, and you should choose values appropriate to your use case. If any elements created in the view for your results can be picked with the -keyboard or mouse, then be sure to implement your provider's ``onEngagement`` -method. +keyboard or mouse, then be sure to implement your provider's +``onLegacyEngagement`` method. For help on implementing providers in general, see the address bar's `Architecture Overview`__. @@ -616,7 +616,7 @@ URL Navigation If a result's payload includes a string ``url`` property and a boolean ``shouldNavigate: true`` property, then picking the result will navigate to the -URL. The ``onEngagement`` method of the result's provider will still be called +URL. The ``onLegacyEngagement`` method of the result's provider will still be called before navigation. Text Highlighting diff --git a/browser/components/urlbar/metrics.yaml b/browser/components/urlbar/metrics.yaml index 95337d84eb..173ee08a10 100644 --- a/browser/components/urlbar/metrics.yaml +++ b/browser/components/urlbar/metrics.yaml @@ -110,7 +110,6 @@ urlbar: `intervention_unknown`, `intervention_update`, `keyword`, - `merino_adm_nonsponsored`, `merino_adm_sponsored`, `merino_amo`, `merino_top_picks`, @@ -159,6 +158,7 @@ urlbar: notification_emails: - fx-search-telemetry@mozilla.com expires: never + engagement: type: event description: Recorded when the user executes an action on a result. @@ -242,7 +242,6 @@ urlbar: `intervention_unknown`, `intervention_update`, `keyword`, - `merino_adm_nonsponsored`, `merino_adm_sponsored`, `merino_amo`, `merino_top_picks`, @@ -363,7 +362,6 @@ urlbar: `intervention_unknown`, `intervention_update`, `keyword`, - `merino_adm_nonsponsored`, `merino_adm_sponsored`, `merino_amo`, `merino_top_picks`, @@ -432,6 +430,40 @@ urlbar: - fx-search-telemetry@mozilla.com expires: never + potential_exposure: + type: event + description: > + This event is recorded in the `urlbar-potential-exposure` ping, which is + submitted at the end of urlbar sessions during which the user typed a + keyword defined by the Nimbus variable `potentialExposureKeywords`. A + "session" begins when the user focuses the urlbar and ends with an + engagement or abandonment. The ping will contain one event per unique + keyword that is typed during the session. This ping is not submitted for + sessions in private windows. + extra_keys: + keyword: + type: string + description: > + The matched keyword. + terminal: + type: boolean + description: > + Whether the matched keyword was present at the end of the urlbar + session. If true, the session ended with the keyword. If false, the + keyword was typed at some point during the session but the session + did not end with it. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1881875 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1881875 + data_sensitivity: + - stored_content + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + send_in_pings: + - urlbar-potential-exposure + quick_suggest_contextual_opt_in: type: event description: > diff --git a/browser/components/urlbar/pings.yaml b/browser/components/urlbar/pings.yaml index 8153b62863..4c46f16909 100644 --- a/browser/components/urlbar/pings.yaml +++ b/browser/components/urlbar/pings.yaml @@ -19,3 +19,19 @@ quick-suggest: - https://bugzilla.mozilla.org/show_bug.cgi?id=1854755 notification_emails: - najiang@mozilla.com + +urlbar-potential-exposure: + description: | + This ping is submitted at the end of urlbar sessions during which the user + typed a keyword defined by the Nimbus variable `potentialExposureKeywords`. + A "session" begins when the user focuses the urlbar and ends with an + engagement or abandonment. The ping will contain one + `urlbar.potential_exposure` event per unique keyword that is typed during + the session. This ping is not submitted for sessions in private windows. + include_client_id: false + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1881875 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1881875 + notification_emails: + - fx-search-telemetry@mozilla.com diff --git a/browser/components/urlbar/private/AddonSuggestions.sys.mjs b/browser/components/urlbar/private/AddonSuggestions.sys.mjs index 23311cec1c..ace82e41d3 100644 --- a/browser/components/urlbar/private/AddonSuggestions.sys.mjs +++ b/browser/components/urlbar/private/AddonSuggestions.sys.mjs @@ -21,7 +21,7 @@ const UTM_PARAMS = { }; const RESULT_MENU_COMMAND = { - HELP: "help", + MANAGE: "manage", NOT_INTERESTED: "not_interested", NOT_RELEVANT: "not_relevant", SHOW_LESS_FREQUENTLY: "show_less_frequently", @@ -212,9 +212,9 @@ export class AddonSuggestions extends BaseFeature { }, { name: "separator" }, { - name: RESULT_MENU_COMMAND.HELP, + name: RESULT_MENU_COMMAND.MANAGE, l10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", + id: "urlbar-result-menu-manage-firefox-suggest", }, } ); @@ -224,8 +224,8 @@ export class AddonSuggestions extends BaseFeature { handleCommand(view, result, selType) { switch (selType) { - case RESULT_MENU_COMMAND.HELP: - // "help" is handled by UrlbarInput, no need to do anything here. + case RESULT_MENU_COMMAND.MANAGE: + // "manage" is handled by UrlbarInput, no need to do anything here. break; // selType == "dismiss" when the user presses the dismiss key shortcut. case "dismiss": diff --git a/browser/components/urlbar/private/AdmWikipedia.sys.mjs b/browser/components/urlbar/private/AdmWikipedia.sys.mjs index 3ab5bad09f..596e15df4c 100644 --- a/browser/components/urlbar/private/AdmWikipedia.sys.mjs +++ b/browser/components/urlbar/private/AdmWikipedia.sys.mjs @@ -190,14 +190,11 @@ export class AdmWikipedia extends BaseFeature { sponsoredBlockId: suggestion.block_id, sponsoredAdvertiser: suggestion.advertiser, sponsoredIabCategory: suggestion.iab_category, - helpUrl: lazy.QuickSuggest.HELP_URL, - helpL10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", - }, isBlockable: true, blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest", }, + isManageable: true, }; let result = new lazy.UrlbarResult( diff --git a/browser/components/urlbar/private/MDNSuggestions.sys.mjs b/browser/components/urlbar/private/MDNSuggestions.sys.mjs index c9e7da18af..3efedbd12a 100644 --- a/browser/components/urlbar/private/MDNSuggestions.sys.mjs +++ b/browser/components/urlbar/private/MDNSuggestions.sys.mjs @@ -15,7 +15,7 @@ ChromeUtils.defineESModuleGetters(lazy, { }); const RESULT_MENU_COMMAND = { - HELP: "help", + MANAGE: "manage", NOT_INTERESTED: "not_interested", NOT_RELEVANT: "not_relevant", }; @@ -157,9 +157,9 @@ export class MDNSuggestions extends BaseFeature { }, { name: "separator" }, { - name: RESULT_MENU_COMMAND.HELP, + name: RESULT_MENU_COMMAND.MANAGE, l10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", + id: "urlbar-result-menu-manage-firefox-suggest", }, }, ]; @@ -167,8 +167,8 @@ export class MDNSuggestions extends BaseFeature { handleCommand(view, result, selType) { switch (selType) { - case RESULT_MENU_COMMAND.HELP: - // "help" is handled by UrlbarInput, no need to do anything here. + case RESULT_MENU_COMMAND.MANAGE: + // "manage" is handled by UrlbarInput, no need to do anything here. break; // selType == "dismiss" when the user presses the dismiss key shortcut. case "dismiss": diff --git a/browser/components/urlbar/private/SuggestBackendRust.sys.mjs b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs index 2d96e7540f..3993149757 100644 --- a/browser/components/urlbar/private/SuggestBackendRust.sys.mjs +++ b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs @@ -136,11 +136,12 @@ export class SuggestBackendRust extends BaseFeature { suggestion.provider = type; suggestion.is_sponsored = type == "Amp" || type == "Yelp"; if (Array.isArray(suggestion.icon)) { - suggestion.icon_blob = new Blob( - [new Uint8Array(suggestion.icon)], - type == "Yelp" ? { type: "image/svg+xml" } : null - ); + suggestion.icon_blob = new Blob([new Uint8Array(suggestion.icon)], { + type: suggestion.iconMimetype ?? "", + }); + delete suggestion.icon; + delete suggestion.iconMimetype; } } diff --git a/browser/components/urlbar/private/YelpSuggestions.sys.mjs b/browser/components/urlbar/private/YelpSuggestions.sys.mjs index 4cf454c71d..e2a2803bd7 100644 --- a/browser/components/urlbar/private/YelpSuggestions.sys.mjs +++ b/browser/components/urlbar/private/YelpSuggestions.sys.mjs @@ -15,8 +15,8 @@ ChromeUtils.defineESModuleGetters(lazy, { }); const RESULT_MENU_COMMAND = { - HELP: "help", INACCURATE_LOCATION: "inaccurate_location", + MANAGE: "manage", NOT_INTERESTED: "not_interested", NOT_RELEVANT: "not_relevant", SHOW_LESS_FREQUENTLY: "show_less_frequently", @@ -168,9 +168,9 @@ export class YelpSuggestions extends BaseFeature { }, { name: "separator" }, { - name: RESULT_MENU_COMMAND.HELP, + name: RESULT_MENU_COMMAND.MANAGE, l10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", + id: "urlbar-result-menu-manage-firefox-suggest", }, } ); @@ -180,8 +180,8 @@ export class YelpSuggestions extends BaseFeature { handleCommand(view, result, selType, searchString) { switch (selType) { - case RESULT_MENU_COMMAND.HELP: - // "help" is handled by UrlbarInput, no need to do anything here. + case RESULT_MENU_COMMAND.MANAGE: + // "manage" is handled by UrlbarInput, no need to do anything here. break; case RESULT_MENU_COMMAND.INACCURATE_LOCATION: // Currently the only way we record this feedback is in the Glean diff --git a/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs index cfc9ecb3d8..f576f4ca19 100644 --- a/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs +++ b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs @@ -158,7 +158,7 @@ export var UrlbarTestUtils = { lazy.UrlbarPrefs.get("trimURLs") && value != lazy.BrowserUIUtils.trimURL(value) ) { - window.gURLBar._setValue(value, false); + window.gURLBar._setValue(value); fireInputEvent = true; } else { window.gURLBar.value = value; @@ -1315,10 +1315,7 @@ export var UrlbarTestUtils = { // Set most of the string directly instead of going through sendString, // so that we don't make life unnecessarily hard for consumers by // possibly starting multiple searches. - win.gURLBar._setValue( - text.substr(0, text.length - 1), - false /* allowTrim = */ - ); + win.gURLBar._setValue(text.substr(0, text.length - 1)); } this.EventUtils.sendString(text.substr(-1, 1), win); }, @@ -1490,7 +1487,7 @@ class TestProvider extends UrlbarProvider { * @param {Function} [options.onSelection] * If given, a function that will be called when * {@link UrlbarView.#selectElement} method is called. - * @param {Function} [options.onEngagement] + * @param {Function} [options.onLegacyEngagement] * If given, a function that will be called when engagement. * @param {Function} [options.delayResultsPromise] * If given, we'll await on this before returning results. @@ -1503,7 +1500,7 @@ class TestProvider extends UrlbarProvider { addTimeout = 0, onCancel = null, onSelection = null, - onEngagement = null, + onLegacyEngagement = null, delayResultsPromise = null, } = {}) { if (delayResultsPromise && addTimeout) { @@ -1520,7 +1517,7 @@ class TestProvider extends UrlbarProvider { this._type = type; this._onCancel = onCancel; this._onSelection = onSelection; - this._onEngagement = onEngagement; + this._onLegacyEngagement = onLegacyEngagement; // As this has been a common source of mistakes, auto-upgrade the provider // type to heuristic if any result is heuristic. @@ -1574,8 +1571,8 @@ class TestProvider extends UrlbarProvider { this._onSelection?.(result, element); } - onEngagement(state, queryContext, details, controller) { - this._onEngagement?.(state, queryContext, details, controller); + onLegacyEngagement(state, queryContext, details, controller) { + this._onLegacyEngagement?.(state, queryContext, details, controller); } } diff --git a/browser/components/urlbar/tests/browser-tips/browser_picks.js b/browser/components/urlbar/tests/browser-tips/browser_picks.js index ba0ff69357..c9d725dfb5 100644 --- a/browser/components/urlbar/tests/browser-tips/browser_picks.js +++ b/browser/components/urlbar/tests/browser-tips/browser_picks.js @@ -117,8 +117,8 @@ async function doTest({ click, buttonUrl = undefined, helpUrl = undefined }) { }); UrlbarProvidersManager.registerProvider(provider); - let onEngagementPromise = new Promise( - resolve => (provider.onEngagement = resolve) + let onLegacyEngagementPromise = new Promise( + resolve => (provider.onLegacyEngagement = resolve) ); // Do a search to show our tip result. @@ -142,8 +142,8 @@ async function doTest({ click, buttonUrl = undefined, helpUrl = undefined }) { ); } - // Now pick the target and wait for provider.onEngagement to be called and - // the URL to load if necessary. + // Now pick the target and wait for provider.onLegacyEngagement to be called + // and the URL to load if necessary. let loadPromise; if (buttonUrl || helpUrl) { loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); @@ -160,7 +160,7 @@ async function doTest({ click, buttonUrl = undefined, helpUrl = undefined }) { EventUtils.synthesizeKey("KEY_Enter"); } }); - await onEngagementPromise; + await onLegacyEngagementPromise; await loadPromise; // Check telemetry. diff --git a/browser/components/urlbar/tests/browser-tips/browser_searchTips.js b/browser/components/urlbar/tests/browser-tips/browser_searchTips.js index a82a2d658b..8c98e27993 100644 --- a/browser/components/urlbar/tests/browser-tips/browser_searchTips.js +++ b/browser/components/urlbar/tests/browser-tips/browser_searchTips.js @@ -18,7 +18,7 @@ ChromeUtils.defineESModuleGetters(this, { "resource:///modules/UrlbarProviderSearchTips.sys.mjs", }); -// These should match the same consts in UrlbarProviderSearchTips.jsm. +// These should match the same consts in UrlbarProviderSearchTips.sys.mjs. const MAX_SHOWN_COUNT = 4; const LAST_UPDATE_THRESHOLD_MS = 24 * 60 * 60 * 1000; diff --git a/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js b/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js index 72d05cf632..6c0550a2df 100644 --- a/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js +++ b/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js @@ -25,7 +25,7 @@ XPCOMUtils.defineLazyServiceGetter( "nsIClipboardHelper" ); -// These should match the same consts in UrlbarProviderSearchTips.jsm. +// These should match the same consts in UrlbarProviderSearchTips.sys.mjs. const MAX_SHOWN_COUNT = 4; const LAST_UPDATE_THRESHOLD_MS = 24 * 60 * 60 * 1000; diff --git a/browser/components/urlbar/tests/browser/browser.toml b/browser/components/urlbar/tests/browser/browser.toml index b9934aa838..44b964e5ca 100644 --- a/browser/components/urlbar/tests/browser/browser.toml +++ b/browser/components/urlbar/tests/browser/browser.toml @@ -4,7 +4,11 @@ support-files = [ "head.js", "head-common.js", ] - +skip-if = [ + "os == 'linux' && os_version == '18.04' && asan", # long running manifest + "os == 'linux' && os_version == '18.04' && tsan", # long running manifest + "win11_2009 && asan", # long running manifest +] prefs = [ "browser.bookmarks.testing.skipDefaultBookmarksImport=true", "browser.urlbar.trending.featureGate=false", @@ -280,6 +284,8 @@ support-files = [ ["browser_keyword_select_and_type.js"] +["browser_less_common_selection_manipulations.js"] + ["browser_loadRace.js"] ["browser_locationBarCommand.js"] @@ -398,9 +404,6 @@ https_first_disabled = true ["browser_revert.js"] -["browser_search_continuation.js"] -support-files = ["search-engines", "../../../search/test/browser/trendingSuggestionEngine.sjs"] - ["browser_searchFunction.js"] ["browser_searchHistoryLimit.js"] @@ -490,6 +493,9 @@ support-files = [ ["browser_search_bookmarks_from_bookmarks_menu.js"] +["browser_search_continuation.js"] +support-files = ["search-engines", "../../../search/test/browser/trendingSuggestionEngine.sjs"] + ["browser_search_history_from_history_panel.js"] ["browser_selectStaleResults.js"] diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js index f191cae321..d01734959a 100644 --- a/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js @@ -2,7 +2,7 @@ * http://creativecommons.org/publicdomain/zero/1.0/ */ -async function testVal(aExpected, overflowSide = "") { +async function testVal(aExpected, overflowSide = null) { info(`Testing ${aExpected}`); try { gURLBar.setURI(makeURI(aExpected)); diff --git a/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js b/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js index 427a7419c8..bb710c7065 100644 --- a/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js +++ b/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js @@ -98,7 +98,7 @@ add_task(async function clearURLBarAfterManuallyLoadingAboutHome() { () => {} ); // This opens about:newtab: - BrowserOpenTab(); + BrowserCommands.openTab(); let tab = await promiseTabOpenedAndSwitchedTo; is(gURLBar.value, "", "URL bar should be empty"); is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); @@ -132,7 +132,7 @@ add_task(async function dontTemporarilyShowAboutHome() { let win = OpenBrowserWindow(); await windowOpenedPromise; let promiseTabSwitch = BrowserTestUtils.switchTab(win.gBrowser, () => {}); - win.BrowserOpenTab(); + win.BrowserCommands.openTab(); await promiseTabSwitch; currentBrowser = win.gBrowser.selectedBrowser; is(win.gBrowser.visibleTabs.length, 2, "2 tabs opened"); diff --git a/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js b/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js index 8c4b05501e..54f40a85ee 100644 --- a/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js +++ b/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js @@ -389,11 +389,11 @@ class TestProvider extends UrlbarTestUtils.TestProvider { ]; } - onEngagement(state, queryContext, details, controller) { + onLegacyEngagement(state, queryContext, details, controller) { if (details.result?.providerName == this.name) { let { selType } = details; - info(`onEngagement called, selType=` + selType); + info(`onLegacyEngagement called, selType=` + selType); if (!this.commandCount.hasOwnProperty(selType)) { this.commandCount[selType] = 0; diff --git a/browser/components/urlbar/tests/browser/browser_copy_during_load.js b/browser/components/urlbar/tests/browser/browser_copy_during_load.js index 3eaa53bcda..e1d352a171 100644 --- a/browser/components/urlbar/tests/browser/browser_copy_during_load.js +++ b/browser/components/urlbar/tests/browser/browser_copy_during_load.js @@ -45,7 +45,7 @@ add_task(async function () { null, true ); - BrowserStop(); + BrowserCommands.stop(); await browserStoppedPromise; }); }); diff --git a/browser/components/urlbar/tests/browser/browser_dynamicResults.js b/browser/components/urlbar/tests/browser/browser_dynamicResults.js index aad15e0145..2ba1b7ab5f 100644 --- a/browser/components/urlbar/tests/browser/browser_dynamicResults.js +++ b/browser/components/urlbar/tests/browser/browser_dynamicResults.js @@ -511,7 +511,7 @@ add_task(async function shouldNavigate() { await UrlbarTestUtils.promisePopupClose(window, () => EventUtils.synthesizeKey("KEY_Enter") ); - // Verify that onEngagement was still called. + // Verify that onLegacyEngagement was still called. let [result, pickedElement] = await pickPromise; Assert.equal(result, row.result, "Picked result"); Assert.equal(pickedElement, element, "Picked element"); @@ -904,7 +904,7 @@ class TestProvider extends UrlbarTestUtils.TestProvider { }; } - onEngagement(state, queryContext, details, _controller) { + onLegacyEngagement(state, queryContext, details, _controller) { if (this._pickPromiseResolve) { let { result, element } = details; this._pickPromiseResolve([result, element]); diff --git a/browser/components/urlbar/tests/browser/browser_engagement.js b/browser/components/urlbar/tests/browser/browser_engagement.js index b1998b6f55..fbc321e322 100644 --- a/browser/components/urlbar/tests/browser/browser_engagement.js +++ b/browser/components/urlbar/tests/browser/browser_engagement.js @@ -1,7 +1,7 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ -// Tests the UrlbarProvider.onEngagement() method. +// Tests the UrlbarProvider.onLegacyEngagement() method. "use strict"; @@ -110,32 +110,21 @@ async function doTest({ let provider = new TestProvider(); UrlbarProvidersManager.registerProvider(provider); - let startPromise = provider.promiseEngagement(); await UrlbarTestUtils.promiseAutocompleteResultPopup({ window: win, value: "test", fireInputEvent: true, }); - let [state, queryContext, details, controller] = await startPromise; - Assert.equal( - controller.input.isPrivate, - expectedIsPrivate, - "Start isPrivate" - ); - Assert.equal(state, "start", "Start state"); - - // `queryContext` isn't always defined for `start`, and `onEngagement` - // shouldn't rely on it being defined on start, but there's no good reason to - // assert that it's not defined here. - - // Similarly, `details` is never defined for `start`, but there's no good - // reason to assert that it's not defined. - let endPromise = provider.promiseEngagement(); let { result, element } = (await endEngagement()) ?? {}; - [state, queryContext, details, controller] = await endPromise; + let [state, queryContext, details, controller] = await endPromise; + + Assert.ok( + ["engagement", "abandonment"].includes(state), + "State should be either 'engagement' or 'abandonment'" + ); Assert.equal(controller.input.isPrivate, expectedIsPrivate, "End isPrivate"); Assert.equal(state, expectedEndState, "End state"); Assert.ok(queryContext, "End queryContext"); @@ -179,7 +168,7 @@ async function doTest({ } /** - * Test provider that resolves promises when onEngagement is called. + * Test provider that resolves promises when onLegacyEngagement is called. */ class TestProvider extends UrlbarTestUtils.TestProvider { _resolves = []; @@ -197,7 +186,7 @@ class TestProvider extends UrlbarTestUtils.TestProvider { }); } - onEngagement(...args) { + onLegacyEngagement(...args) { let resolve = this._resolves.shift(); if (resolve) { resolve(args); diff --git a/browser/components/urlbar/tests/browser/browser_less_common_selection_manipulations.js b/browser/components/urlbar/tests/browser/browser_less_common_selection_manipulations.js new file mode 100644 index 0000000000..2ad6ee0e07 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_less_common_selection_manipulations.js @@ -0,0 +1,288 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests less common mouse/keyboard manipulations of the address bar input + * field selection, for example: + * - Home/Del + * - Shift+Right/Left + * - Drag selection + * - Double-click on word + * + * All the tests set up some initial conditions, and check it. Then optionally + * they can manipulate the selection further, and check the results again. + * We want to ensure the final selection is the expected one, even if in the + * future we change our trimming strategy for the input field value. + */ + +const tests = [ + { + description: "Test HOME starting from full selection", + openPanel() { + EventUtils.synthesizeKey("l", { accelKey: true }); + }, + get selection() { + return [0, gURLBar.value.length]; + }, + manipulate() { + // Cursor must move to the first visible character, regardless of any + // "untrimming" we could be doing. + this._visibleValue = gURLBar.value; + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("KEY_ArrowLeft", { metaKey: true }); + } else { + EventUtils.synthesizeKey("KEY_Home"); + } + }, + get modifiedSelection() { + let start = gURLBar.value.indexOf(this._visibleValue); + return [start, start]; + }, + }, + { + description: "Test END starting from full selection", + openPanel() { + EventUtils.synthesizeKey("l", { accelKey: true }); + }, + get selection() { + return [0, gURLBar.value.length]; + }, + manipulate() { + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("KEY_ArrowRight", { metaKey: true }); + } else { + EventUtils.synthesizeKey("KEY_End", {}); + } + }, + get modifiedSelection() { + return [gURLBar.value.length, gURLBar.value.length]; + }, + }, + { + description: "Test SHIFT+LEFT starting from full selection", + openPanel() { + EventUtils.synthesizeKey("l", { accelKey: true }); + }, + get selection() { + return [0, gURLBar.value.length]; + }, + manipulate() { + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }); + }, + get modifiedSelection() { + return [0, gURLBar.value.length - 1]; + }, + }, + { + description: "Test SHIFT+RIGHT starting from full selection", + openPanel() { + EventUtils.synthesizeKey("l", { accelKey: true }); + }, + get selection() { + return [0, gURLBar.value.length]; + }, + manipulate() { + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }); + }, + get modifiedSelection() { + return [0, gURLBar.value.length]; + }, + }, + { + description: "Test Drag Selection from the first character", + async openPanel() { + this._expectedSelectedText = gURLBar.value.substring(0, 5); + await selectWithMouseDrag( + getTextWidth(gURLBar.value[0]) / 2 - 1, + getTextWidth(gURLBar.value.substring(0, 5)) + ); + }, + get selection() { + return [ + 0, + gURLBar.value.indexOf(this._expectedSelectedText) + + this._expectedSelectedText.length, + ]; + }, + }, + { + description: "Test Drag Selection from the last character", + async openPanel() { + this._expectedSelectedText = gURLBar.value.substring(-5); + await selectWithMouseDrag( + getTextWidth(gURLBar.value) + 1, + getTextWidth(this._expectedSelectedText) + ); + }, + get selection() { + return [ + gURLBar.value.indexOf(this._expectedSelectedText), + gURLBar.value.length, + ]; + }, + }, + { + description: "Test Drag Selection in the middle of the string", + async openPanel() { + this._expectedSelectedText = gURLBar.value.substring(5, 10); + await selectWithMouseDrag( + getTextWidth(gURLBar.value.substring(0, 5)), + getTextWidth(gURLBar.value.substring(0, 10)) + ); + }, + get selection() { + let start = gURLBar.value.indexOf(this._expectedSelectedText); + return [start, start + this._expectedSelectedText.length]; + }, + }, + { + description: "Test Double-click on word", + async openPanel() { + let wordBoundaryIndex = gURLBar.value.search(/\btest/); + this._expectedSelectedText = "test"; + await selectWithDoubleClick( + getTextWidth(gURLBar.value.substring(0, wordBoundaryIndex)) + ); + }, + get selection() { + let start = gURLBar.value.indexOf(this._expectedSelectedText); + return [start, start + this._expectedSelectedText.length]; + }, + }, + { + description: "Click at the right of the text", + openPanel() { + EventUtils.synthesizeKey("l", { accelKey: true }); + }, + get selection() { + return [0, gURLBar.value.length]; + }, + manipulate() { + let rect = gURLBar.inputField.getBoundingClientRect(); + EventUtils.synthesizeMouse( + gURLBar.inputField, + getTextWidth(gURLBar.value) + 10, + rect.height / 2, + {} + ); + }, + get modifiedSelection() { + return [gURLBar.value.length, gURLBar.value.length]; + }, + }, +]; + +add_setup(async function () { + gURLBar.inputField.style.font = "14px monospace"; + registerCleanupFunction(() => { + gURLBar.inputField.style.font = null; + }); +}); + +add_task(async function https() { + await doTest("https://example.com/test/some/page.htm"); +}); + +add_task(async function http() { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + await doTest("http://example.com/test/other/page.htm"); +}); + +async function doTest(url) { + await BrowserTestUtils.withNewTab(url, async () => { + for (let test of tests) { + gURLBar.blur(); + info(test.description); + await UrlbarTestUtils.promisePopupOpen(window, async () => { + await test.openPanel(); + }); + info( + `Selected text is <${gURLBar.value.substring( + gURLBar.selectionStart, + gURLBar.selectionEnd + )}>` + ); + Assert.deepEqual( + test.selection, + [gURLBar.selectionStart, gURLBar.selectionEnd], + "Check selection" + ); + + if (test.manipulate) { + await test.manipulate(); + info( + `Selected text is <${gURLBar.value.substring( + gURLBar.selectionStart, + gURLBar.selectionEnd + )}>` + ); + Assert.deepEqual( + test.modifiedSelection, + [gURLBar.selectionStart, gURLBar.selectionEnd], + "Check selection after manipulation" + ); + } + } + }); +} + +function getTextWidth(inputText) { + const canvas = + getTextWidth.canvas || + (getTextWidth.canvas = document.createElement("canvas")); + let context = canvas.getContext("2d"); + context.font = window + .getComputedStyle(gURLBar.inputField) + .getPropertyValue("font"); + return context.measureText(inputText).width; +} + +function selectWithMouseDrag(fromX, toX) { + let target = gURLBar.inputField; + let rect = target.getBoundingClientRect(); + let promise = BrowserTestUtils.waitForEvent(target, "mouseup"); + EventUtils.synthesizeMouse( + target, + fromX, + rect.height / 2, + { type: "mousemove" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + fromX, + rect.height / 2, + { type: "mousedown" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + toX, + rect.height / 2, + { type: "mousemove" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + toX, + rect.height / 2, + { type: "mouseup" }, + target.ownerGlobal + ); + return promise; +} + +function selectWithDoubleClick(offsetX) { + let target = gURLBar.inputField; + let rect = target.getBoundingClientRect(); + let promise = BrowserTestUtils.waitForEvent(target, "dblclick"); + EventUtils.synthesizeMouse(target, offsetX, rect.height / 2, { + clickCount: 1, + }); + EventUtils.synthesizeMouse(target, offsetX, rect.height / 2, { + clickCount: 2, + }); + return promise; +} diff --git a/browser/components/urlbar/tests/browser/browser_locationBarCommand.js b/browser/components/urlbar/tests/browser/browser_locationBarCommand.js index 84c45e586a..92409f979f 100644 --- a/browser/components/urlbar/tests/browser/browser_locationBarCommand.js +++ b/browser/components/urlbar/tests/browser/browser_locationBarCommand.js @@ -276,7 +276,7 @@ async function typeAndCommand(eventType, details = {}) { async function triggerCommand(eventType, details = {}) { Assert.equal( await UrlbarTestUtils.promiseUserContextId(window), - gBrowser.selectedTab.getAttribute("usercontextid"), + gBrowser.selectedTab.getAttribute("usercontextid") || "", "userContextId must be the same as the originating tab" ); diff --git a/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js index 2f8e871bfe..66a8ed3a41 100644 --- a/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js +++ b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js @@ -42,7 +42,7 @@ add_task(async function () { // tab button. let userInput = window.windowUtils.setHandlingUserInput(true); try { - BrowserOpenTab(); + BrowserCommands.openTab(); } finally { userInput.destruct(); } diff --git a/browser/components/urlbar/tests/browser/browser_raceWithTabs.js b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js index 17560ea101..821aa0f0ee 100644 --- a/browser/components/urlbar/tests/browser/browser_raceWithTabs.js +++ b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js @@ -41,7 +41,7 @@ add_task(async function hitEnterLoadInRightTab() { gBrowser.tabContainer, "TabOpen" ); - BrowserOpenTab(); + BrowserCommands.openTab(); let oldTab = (await oldTabOpenPromise).target; let oldTabLoadedPromise = BrowserTestUtils.browserLoaded( oldTab.linkedBrowser, @@ -60,7 +60,7 @@ add_task(async function hitEnterLoadInRightTab() { EventUtils.sendKey("return"); info("Immediately open a second tab"); - BrowserOpenTab(); + BrowserCommands.openTab(); let newTab = (await tabOpenPromise).target; info("Created new tab; waiting for tabs to load"); diff --git a/browser/components/urlbar/tests/browser/browser_result_menu.js b/browser/components/urlbar/tests/browser/browser_result_menu.js index ccbe247598..f00b92fa63 100644 --- a/browser/components/urlbar/tests/browser/browser_result_menu.js +++ b/browser/components/urlbar/tests/browser/browser_result_menu.js @@ -201,10 +201,10 @@ add_task(async function firefoxSuggest() { ], }); - // Implement the provider's `onEngagement()` so it removes the result. - let onEngagementCallCount = 0; - provider.onEngagement = (state, queryContext, details, controller) => { - onEngagementCallCount++; + // Implement the provider's `onLegacyEngagement()` so it removes the result. + let onLegacyEngagementCallCount = 0; + provider.onLegacyEngagement = (state, queryContext, details, controller) => { + onLegacyEngagementCallCount++; controller.removeResult(details.result); }; @@ -245,9 +245,9 @@ add_task(async function firefoxSuggest() { }); Assert.greater( - onEngagementCallCount, + onLegacyEngagementCallCount, 0, - "onEngagement() should have been called" + "onLegacyEngagement() should have been called" ); Assert.equal( UrlbarTestUtils.getResultCount(window), diff --git a/browser/components/urlbar/tests/browser/browser_stop_pending.js b/browser/components/urlbar/tests/browser/browser_stop_pending.js index 50f5dfdeec..938a57dc28 100644 --- a/browser/components/urlbar/tests/browser/browser_stop_pending.js +++ b/browser/components/urlbar/tests/browser/browser_stop_pending.js @@ -125,7 +125,7 @@ add_task(async function () { null, true ); - BrowserStop(); + BrowserCommands.stop(); await browserStoppedPromise; is( @@ -207,7 +207,7 @@ add_task(async function () { null, true ); - BrowserStop(); + BrowserCommands.stop(); await browserStoppedPromise; is( diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js index 318b29ad19..b2591a0c14 100644 --- a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js @@ -344,8 +344,8 @@ async function impressions_test(isOnboarding) { 5 ); - // See javadoc for UrlbarProviderTabToSearch.onEngagement for discussion - // about retained results. + // See javadoc for UrlbarProviderTabToSearch.onLegacyEngagement for + // discussion about retained results. info("Reopen the result set with retained results. Record impression."); await UrlbarTestUtils.promisePopupOpen(window, () => { EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml index cf6bc80318..a72f2d9b8d 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml @@ -26,8 +26,6 @@ prefs = ["browser.bookmarks.testing.skipDefaultBookmarksImport=true"] ["browser_glean_telemetry_abandonment_n_chars_n_words.js"] -["browser_glean_telemetry_abandonment_type.js"] - ["browser_glean_telemetry_abandonment_sap.js"] ["browser_glean_telemetry_abandonment_search_engine_default_id.js"] @@ -36,6 +34,8 @@ prefs = ["browser.bookmarks.testing.skipDefaultBookmarksImport=true"] ["browser_glean_telemetry_abandonment_tips.js"] +["browser_glean_telemetry_abandonment_type.js"] + ["browser_glean_telemetry_engagement_edge_cases.js"] ["browser_glean_telemetry_engagement_groups.js"] @@ -66,4 +66,8 @@ skip-if = ["verify"] # Bug 1852375 - MerinoTestUtils.initWeather() doesn't play ["browser_glean_telemetry_exposure_edge_cases.js"] +["browser_glean_telemetry_potential_exposure.js"] + ["browser_glean_telemetry_record_preferences.js"] + +["browser_glean_telemetry_reenter.js"] diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_type.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_type.js index 99145d7cc3..b8a16bd10c 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_type.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_type.js @@ -19,7 +19,7 @@ function checkUrlbarFocus(win, focusState) { // URL bar records the correct abandonment telemetry with abandonment type // "tab_swtich". add_task(async function tabSwitchFocusedToFocused() { - await doTest(async browser => { + await doTest(async () => { await UrlbarTestUtils.promiseAutocompleteResultPopup({ window, value: "test search", @@ -45,7 +45,7 @@ add_task(async function tabSwitchFocusedToFocused() { // URL bar loses focus logs abandonment telemetry with abandonment type // "blur". add_task(async function tabSwitchFocusedToUnfocused() { - await doTest(async browser => { + await doTest(async () => { await UrlbarTestUtils.promiseAutocompleteResultPopup({ window, value: "test search", @@ -65,7 +65,7 @@ add_task(async function tabSwitchFocusedToUnfocused() { // the URL bar gains focus does not record any abandonment telemetry, reflecting // no change in focus state relevant to abandonment. add_task(async function tabSwitchUnFocusedToFocused() { - await doTest(async browser => { + await doTest(async () => { checkUrlbarFocus(window, false); let promiseTabOpened = BrowserTestUtils.waitForEvent( @@ -91,7 +91,7 @@ add_task(async function tabSwitchUnFocusedToFocused() { // Checks that switching between two tabs, both with unfocused URL bars, does // not trigger any abandonment telmetry. add_task(async function tabSwitchUnFocusedToUnFocused() { - await doTest(async browser => { + await doTest(async () => { checkUrlbarFocus(window, false); let tab2 = await BrowserTestUtils.openNewForegroundTab(window.gBrowser); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js index ff31bdc52a..053d307088 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js @@ -63,7 +63,7 @@ add_task(async function selected_result_tip() { ), ], priority: 1, - onEngagement: () => { + onLegacyEngagement: () => { deferred.resolve(); }, }); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js index 6b1dedbce2..59c4460e52 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js @@ -101,11 +101,32 @@ add_task(async function engagement_type_dismiss() { }); add_task(async function engagement_type_help() { - const cleanupQuickSuggest = await ensureQuickSuggestInit(); + const url = "https://example.com/"; + const helpUrl = "https://example.com/help"; + let provider = new UrlbarTestUtils.TestProvider({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url, + isBlockable: true, + blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest" }, + helpUrl, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + } + ), + ], + }); + UrlbarProvidersManager.registerProvider(provider); await doTest(async () => { - await openPopup("sponsored"); - await selectRowByURL("https://example.com/sponsored"); + await openPopup("test"); + await selectRowByURL(url); + const onTabOpened = BrowserTestUtils.waitForNewTab(gBrowser); UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L"); const tab = await onTabOpened; @@ -114,5 +135,26 @@ add_task(async function engagement_type_help() { assertEngagementTelemetry([{ engagement_type: "help" }]); }); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function engagement_type_manage() { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + await doTest(async () => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + + const onManagePageLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + "about:preferences#search" + ); + UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "M"); + await onManagePageLoaded; + + assertEngagementTelemetry([{ engagement_type: "manage" }]); + }); + await cleanupQuickSuggest(); }); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js index 07e8b9b360..ef2ec623bc 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js @@ -11,7 +11,7 @@ add_setup(async function () { await initExposureTest(); }); -add_task(async function exposureSponsoredOnEngagement() { +add_task(async function exposureSponsoredOnLegacyEngagement() { await doExposureTest({ prefs: [ ["browser.urlbar.exposureResults", suggestResultType("adm_sponsored")], diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_potential_exposure.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_potential_exposure.js new file mode 100644 index 0000000000..275e3968eb --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_potential_exposure.js @@ -0,0 +1,438 @@ +/* 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/. */ + +// Tests the `urlbar-potential-exposure` ping. + +const WAIT_FOR_PING_TIMEOUT_MS = 1000; + +// Avoid timeouts in verify mode, especially on Mac. +requestLongerTimeout(3); + +add_setup(async function test_setup() { + Services.fog.testResetFOG(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(() => { + Services.fog.testResetFOG(); + }); +}); + +add_task(async function oneKeyword_noMatch_1() { + await doTest({ + keywords: ["example"], + searchStrings: ["exam"], + expectedEvents: [], + }); +}); + +add_task(async function oneKeyword_noMatch_2() { + await doTest({ + keywords: ["exam"], + searchStrings: ["example"], + expectedEvents: [], + }); +}); + +add_task(async function oneKeyword_oneMatch_terminal_1() { + await doTest({ + keywords: ["example"], + searchStrings: ["example"], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function oneKeyword_oneMatch_terminal_2() { + await doTest({ + keywords: ["example"], + searchStrings: ["exam", "example"], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function oneKeyword_oneMatch_nonterminal_1() { + await doTest({ + keywords: ["example"], + searchStrings: ["example", "exam"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function oneKeyword_oneMatch_nonterminal_2() { + await doTest({ + keywords: ["example"], + searchStrings: ["ex", "example", "exam"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_terminal_1() { + await doTest({ + keywords: ["example"], + searchStrings: ["example", "example"], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_terminal_2() { + await doTest({ + keywords: ["example"], + searchStrings: ["example", "exampl", "example"], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_terminal_3() { + await doTest({ + keywords: ["example"], + searchStrings: ["exam", "example", "example"], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_terminal_4() { + await doTest({ + keywords: ["example"], + searchStrings: ["exam", "example", "exampl", "example"], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_nonterminal_1() { + await doTest({ + keywords: ["example"], + searchStrings: ["example", "example", "exampl"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_nonterminal_2() { + await doTest({ + keywords: ["example"], + searchStrings: ["exam", "example", "example", "exampl"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_nonterminal_3() { + await doTest({ + keywords: ["example"], + searchStrings: ["example", "exam", "example", "exampl"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function oneKeyword_dupeMatches_nonterminal_4() { + await doTest({ + keywords: ["example"], + searchStrings: ["exam", "example", "exampl", "example", "exampl"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function manyKeywords_noMatch() { + await doTest({ + keywords: ["foo", "bar", "baz"], + searchStrings: ["example"], + expectedEvents: [], + }); +}); + +add_task(async function manyKeywords_oneMatch_terminal_1() { + await doTest({ + keywords: ["foo", "bar", "baz"], + searchStrings: ["bar"], + expectedEvents: [{ extra: { keyword: "bar", terminal: true } }], + }); +}); + +add_task(async function manyKeywords_oneMatch_terminal_2() { + await doTest({ + keywords: ["foo", "bar", "baz"], + searchStrings: ["example", "bar"], + expectedEvents: [{ extra: { keyword: "bar", terminal: true } }], + }); +}); + +add_task(async function manyKeywords_oneMatch_nonterminal_1() { + await doTest({ + keywords: ["foo", "bar", "baz"], + searchStrings: ["bar", "example"], + expectedEvents: [{ extra: { keyword: "bar", terminal: false } }], + }); +}); + +add_task(async function manyKeywords_oneMatch_nonterminal_2() { + await doTest({ + keywords: ["foo", "bar", "baz"], + searchStrings: ["exam", "bar", "example"], + expectedEvents: [{ extra: { keyword: "bar", terminal: false } }], + }); +}); + +add_task(async function manyKeywords_manyMatches_terminal_1() { + let keywords = ["foo", "bar", "baz"]; + await doTest({ + keywords, + searchStrings: keywords, + expectedEvents: keywords.map((keyword, i) => ({ + extra: { keyword, terminal: i == keywords.length - 1 }, + })), + }); +}); + +add_task(async function manyKeywords_manyMatches_terminal_2() { + let keywords = ["foo", "bar", "baz"]; + await doTest({ + keywords, + searchStrings: ["exam", "foo", "exampl", "bar", "example", "baz"], + expectedEvents: keywords.map((keyword, i) => ({ + extra: { keyword, terminal: i == keywords.length - 1 }, + })), + }); +}); + +add_task(async function manyKeywords_manyMatches_nonterminal_1() { + let keywords = ["foo", "bar", "baz"]; + await doTest({ + keywords, + searchStrings: ["foo", "bar", "baz", "example"], + expectedEvents: keywords.map(keyword => ({ + extra: { keyword, terminal: false }, + })), + }); +}); + +add_task(async function manyKeywords_manyMatches_nonterminal_2() { + let keywords = ["foo", "bar", "baz"]; + await doTest({ + keywords, + searchStrings: ["exam", "foo", "exampl", "bar", "example", "baz", "exam"], + expectedEvents: keywords.map(keyword => ({ + extra: { keyword, terminal: false }, + })), + }); +}); + +add_task(async function manyKeywords_dupeMatches_terminal() { + let keywords = ["foo", "bar", "baz"]; + let searchStrings = [...keywords, ...keywords]; + await doTest({ + keywords, + searchStrings, + expectedEvents: keywords.map((keyword, i) => ({ + extra: { keyword, terminal: i == keywords.length - 1 }, + })), + }); +}); + +add_task(async function manyKeywords_dupeMatches_nonterminal() { + let keywords = ["foo", "bar", "baz"]; + let searchStrings = [...keywords, ...keywords, "example"]; + await doTest({ + keywords, + searchStrings, + expectedEvents: keywords.map(keyword => ({ + extra: { keyword, terminal: false }, + })), + }); +}); + +add_task(async function searchStringNormalization_terminal() { + await doTest({ + keywords: ["example"], + searchStrings: [" ExaMPLe "], + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); +}); + +add_task(async function searchStringNormalization_nonterminal() { + await doTest({ + keywords: ["example"], + searchStrings: [" ExaMPLe ", "foo"], + expectedEvents: [{ extra: { keyword: "example", terminal: false } }], + }); +}); + +add_task(async function multiWordKeyword() { + await doTest({ + keywords: ["this has multiple words"], + searchStrings: ["this has multiple words"], + expectedEvents: [ + { extra: { keyword: "this has multiple words", terminal: true } }, + ], + }); +}); + +// Smoke test that ends a session with an engagement instead of an abandonment +// as other tasks in this file do. +add_task(async function engagement() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await doTest({ + keywords: ["example"], + searchStrings: ["example"], + endSession: () => + // Hit the Enter key on the heuristic search result. + UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ), + expectedEvents: [{ extra: { keyword: "example", terminal: true } }], + }); + }); +}); + +// Smoke test that uses Nimbus to set keywords instead of a pref as other tasks +// in this file do. +add_task(async function nimbus() { + let keywords = ["foo", "bar", "baz"]; + await doTest({ + useNimbus: true, + keywords, + searchStrings: keywords, + expectedEvents: keywords.map((keyword, i) => ({ + extra: { keyword, terminal: i == keywords.length - 1 }, + })), + }); +}); + +// The ping should not be submitted for sessions in private windows. +add_task(async function privateWindow() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await doTest({ + win: privateWin, + keywords: ["example"], + searchStrings: ["example"], + expectedEvents: [], + }); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function invalidPotentialExposureKeywords_pref() { + await doTest({ + keywords: "not an array of keywords", + searchStrings: ["example", "not an array of keywords"], + expectedEvents: [], + }); +}); + +add_task(async function invalidPotentialExposureKeywords_nimbus() { + await doTest({ + useNimbus: true, + keywords: "not an array of keywords", + searchStrings: ["example", "not an array of keywords"], + expectedEvents: [], + }); +}); + +async function doTest({ + keywords, + searchStrings, + expectedEvents, + endSession = null, + useNimbus = false, + win = window, +}) { + endSession ||= () => + UrlbarTestUtils.promisePopupClose(win, () => win.gURLBar.blur()); + + let nimbusCleanup; + let keywordsJson = JSON.stringify(keywords); + if (useNimbus) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({ + potentialExposureKeywords: keywordsJson, + }); + } else { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.potentialExposureKeywords", keywordsJson]], + }); + } + + let pingPromise = waitForPing(); + + for (let value of searchStrings) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value, + window: win, + }); + } + await endSession(); + + // Wait `WAIT_FOR_PING_TIMEOUT_MS` for the ping to be submitted before + // reporting a timeout. Note that some tasks do not expect a ping to be + // submitted, and they rely on this timeout behavior. + info("Awaiting ping promise"); + let events = null; + events = await Promise.race([ + pingPromise, + new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => { + if (!events) { + info("Timed out waiting for ping"); + } + resolve([]); + }, WAIT_FOR_PING_TIMEOUT_MS) + ), + ]); + + assertEvents(events, expectedEvents); + + if (nimbusCleanup) { + await nimbusCleanup(); + } else { + await SpecialPowers.popPrefEnv(); + } + Services.fog.testResetFOG(); +} + +function waitForPing() { + return new Promise(resolve => { + GleanPings.urlbarPotentialExposure.testBeforeNextSubmit(() => { + let events = Glean.urlbar.potentialExposure.testGetValue(); + info("testBeforeNextSubmit got events: " + JSON.stringify(events)); + resolve(events); + }); + }); +} + +function assertEvents(actual, expected) { + info("Comparing events: " + JSON.stringify({ actual, expected })); + + // Add some expected boilerplate properties to the expected events so that + // callers don't have to but so that we still check them. + expected = expected.map(e => ({ + category: "urlbar", + name: "potential_exposure", + // `testGetValue()` stringifies booleans for some reason. Let callers + // specify booleans since booleans are correct, and stringify them here. + ...stringifyBooleans(e), + })); + + // Filter out properties from the actual events that aren't defined in the + // expected events. Ignore unimportant properties like timestamps. + actual = actual.map((a, i) => + Object.fromEntries( + Object.entries(a).filter(([key]) => expected[i]?.hasOwnProperty(key)) + ) + ); + + Assert.deepEqual(actual, expected, "Checking expected Glean events"); +} + +function stringifyBooleans(obj) { + let newObj = {}; + for (let [key, value] of Object.entries(obj)) { + if (value && typeof value == "object") { + newObj[key] = stringifyBooleans(value); + } else if (typeof value == "boolean") { + newObj[key] = String(value); + } else { + newObj[key] = value; + } + } + return newObj; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_reenter.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_reenter.js new file mode 100644 index 0000000000..51bdc84870 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_reenter.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test we don't re-enter record() (and record both an engagement and an +// abandonment) when handling an engagement blurs the input field. + +const TEST_URL = "https://example.com/"; + +add_task(async function () { + await setup(); + let deferred = Promise.withResolvers(); + const provider = new UrlbarTestUtils.TestProvider({ + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: TEST_URL, + helpUrl: "https://example.com/help", + helpL10n: { + id: "urlbar-result-menu-tip-get-help", + }, + } + ), + ], + priority: 999, + onLegacyEngagement: () => { + info("Blur the address bar during the onLegacyEngagement notification"); + gURLBar.blur(); + // Run at the next tick to be sure spurious events would have happened. + TestUtils.waitForTick().then(() => { + deferred.resolve(); + }); + }, + }); + UrlbarProvidersManager.registerProvider(provider); + // This should cover at least engagement and abandonment. + let engagementSpy = sinon.spy(provider, "onLegacyEngagement"); + + let beforeRecordCall = false, + recordReentered = false; + let recordStub = sinon + .stub(gURLBar.controller.engagementEvent, "record") + .callsFake((...args) => { + recordReentered = beforeRecordCall; + beforeRecordCall = true; + recordStub.wrappedMethod.apply(gURLBar.controller.engagementEvent, args); + beforeRecordCall = false; + }); + + registerCleanupFunction(() => { + sinon.restore(); + UrlbarProvidersManager.unregisterProvider(provider); + }); + + await doTest(async () => { + await openPopup("example"); + await selectRowByURL(TEST_URL); + EventUtils.synthesizeKey("VK_RETURN"); + await deferred.promise; + + assertEngagementTelemetry([{ engagement_type: "enter" }]); + assertAbandonmentTelemetry([]); + + Assert.ok(recordReentered, "`record()` was re-entered"); + Assert.equal( + engagementSpy.callCount, + 1, + "`onLegacyEngagement` was invoked twice" + ); + Assert.equal( + engagementSpy.args[0][0], + "engagement", + "`engagement` notified" + ); + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js index 4317a50930..1373cc7e27 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/head.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js @@ -10,6 +10,7 @@ Services.scriptloader.loadSubScript( ChromeUtils.defineESModuleGetters(this, { QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", }); const lazy = {}; @@ -210,12 +211,7 @@ async function doTest(testFn) { await QuickSuggest.blockedSuggestions.clear(); await QuickSuggest.blockedSuggestions._test_readyPromise; await updateTopSites(() => true); - - try { - await BrowserTestUtils.withNewTab(gBrowser, testFn); - } catch (e) { - console.error(e); - } + await BrowserTestUtils.withNewTab(gBrowser, testFn); } async function initGroupTest() { diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs index 2ba9dce8be..1002b4e231 100644 --- a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs +++ b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs @@ -490,6 +490,8 @@ class _QuickSuggestTestUtils { * Whether the result is expected to be sponsored. * @param {boolean} [options.isBestMatch] * Whether the result is expected to be a best match. + * @param {boolean} [options.isManageable] + * Whether the result is expected to show Manage result menu item. * @returns {result} * The quick suggest result. */ @@ -500,6 +502,7 @@ class _QuickSuggestTestUtils { index = -1, isSponsored = true, isBestMatch = false, + isManageable = true, } = {}) { this.Assert.ok( url || originalUrl, @@ -574,11 +577,19 @@ class _QuickSuggestTestUtils { } this.Assert.equal( - result.payload.helpUrl, - lazy.QuickSuggest.HELP_URL, - "Result helpURL" + result.payload.isManageable, + isManageable, + "Result isManageable" ); + if (!isManageable) { + this.Assert.equal( + result.payload.helpUrl, + lazy.QuickSuggest.HELP_URL, + "Result helpURL" + ); + } + this.Assert.ok( row._buttons.get("menu"), "The menu button should be present" diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js index 130afe8c53..98f6ba6117 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js @@ -164,3 +164,40 @@ add_tasks_with_rust( await cleanUpNimbus(); } ); + +// Tests the "Manage" result menu for sponsored suggestion. +add_tasks_with_rust(async function resultMenu_manage_sponsored() { + await BrowserTestUtils.withNewTab({ gBrowser }, async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "fra", + }); + + const managePage = "about:preferences#search"; + let onManagePageLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + managePage + ); + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem(window, "manage", { + resultIndex: 1, + }); + await onManagePageLoaded; + Assert.equal( + browser.currentURI.spec, + managePage, + "The manage page is loaded" + ); + + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests the "Manage" result menu for non-sponsored suggestion. +add_tasks_with_rust(async function resultMenu_manage_nonSponsored() { + await doManageTest({ + input: "nonspon", + index: 1, + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js index b09345aa54..f34b479134 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js @@ -245,10 +245,15 @@ add_task(async function resultMenu_notInterested() { }); // Tests the "Not relevant" result menu dismissal command. -add_task(async function notRelevant() { +add_task(async function resultMenu_notRelevant() { await doDismissTest("not_relevant", false); }); +// Tests the "Manage" result menu. +add_task(async function resultMenu_manage() { + await doManageTest({ input: "only match the Merino suggestion", index: 1 }); +}); + // Tests the row/group label. add_task(async function rowLabel() { await UrlbarTestUtils.promiseAutocompleteResultPopup({ diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js index c400cf72f6..3fa91e5a32 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js @@ -5,11 +5,6 @@ "use strict"; -ChromeUtils.defineESModuleGetters(this, { - CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.sys.mjs", -}); - const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; const { TIMESTAMP_TEMPLATE } = QuickSuggest; diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js index b7da7533c4..33bd37703d 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js @@ -21,6 +21,9 @@ const REMOTE_SETTINGS_DATA = [ }, ]; +// Avoid timeouts in verify mode. They're especially common on Mac. +requestLongerTimeout(5); + add_setup(async function () { await QuickSuggestTestUtils.ensureQuickSuggestInit({ remoteSettingsRecords: REMOTE_SETTINGS_DATA, @@ -28,35 +31,37 @@ add_setup(async function () { }); add_tasks_with_rust(async function basic() { - const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: suggestion.keywords[0], - }); - Assert.equal(UrlbarTestUtils.getResultCount(window), 2); - - const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt( - window, - 1 - ); - Assert.equal( - result.providerName, - UrlbarProviderQuickSuggest.name, - "The result should be from the expected provider" - ); - Assert.equal( - result.payload.provider, - UrlbarPrefs.get("quickSuggestRustEnabled") ? "Mdn" : "MDNSuggestions" - ); + await BrowserTestUtils.withNewTab("about:blank", async () => { + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: suggestion.keywords[0], + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + Assert.equal( + result.providerName, + UrlbarProviderQuickSuggest.name, + "The result should be from the expected provider" + ); + Assert.equal( + result.payload.provider, + UrlbarPrefs.get("quickSuggestRustEnabled") ? "Mdn" : "MDNSuggestions" + ); - const onLoad = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - result.payload.url - ); - EventUtils.synthesizeMouseAtCenter(element.row, {}); - await onLoad; - Assert.ok(true, "Expected page is loaded"); + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + result.payload.url + ); + EventUtils.synthesizeMouseAtCenter(element.row, {}); + await onLoad; + Assert.ok(true, "Expected page is loaded"); + }); await PlacesUtils.history.clear(); }); @@ -111,7 +116,7 @@ add_tasks_with_rust(async function resultMenu_notInterested() { }); // Tests the "Not relevant" result menu dismissal command. -add_tasks_with_rust(async function notRelevant() { +add_tasks_with_rust(async function resultMenu_notRelevant() { await doDismissTest("not_relevant"); Assert.equal(UrlbarPrefs.get("suggest.mdn"), true); @@ -123,6 +128,11 @@ add_tasks_with_rust(async function notRelevant() { await QuickSuggest.blockedSuggestions.clear(); }); +// Tests the "Manage" result menu. +add_tasks_with_rust(async function resultMenu_manage() { + await doManageTest({ input: "array", index: 1 }); +}); + async function doDismissTest(command) { const keyword = REMOTE_SETTINGS_DATA[0].attachment[0].keywords[0]; // Do a search. diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js index 0064b6a297..a40a35893b 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js @@ -4,12 +4,6 @@ "use strict"; // Browser tests for Pocket suggestions. -// -// TODO: Make this work with Rust enabled. Right now, running this test with -// Rust hits the following error on ingest, which prevents ingest from finishing -// successfully: -// -// 0:03.17 INFO Console message: [JavaScript Error: "1698289045697 urlbar ERROR QuickSuggest.SuggestBackendRust :: Ingest error: Error executing SQL: FOREIGN KEY constraint failed" {file: "resource://gre/modules/Log.sys.mjs" line: 722}] // The expected index of the Pocket suggestion. const EXPECTED_RESULT_INDEX = 1; @@ -30,6 +24,8 @@ const REMOTE_SETTINGS_DATA = [ }, ]; +requestLongerTimeout(5); + add_setup(async function () { await SpecialPowers.pushPrefEnv({ set: [ @@ -47,7 +43,7 @@ add_setup(async function () { }); }); -add_task(async function basic() { +add_tasks_with_rust(async function basic() { await BrowserTestUtils.withNewTab("about:blank", async () => { // Do a search. await UrlbarTestUtils.promiseAutocompleteResultPopup({ @@ -96,7 +92,7 @@ add_task(async function basic() { }); // Tests the "Show less frequently" command. -add_task(async function resultMenu_showLessFrequently() { +add_tasks_with_rust(async function resultMenu_showLessFrequently() { await SpecialPowers.pushPrefEnv({ set: [ ["browser.urlbar.pocket.featureGate", true], @@ -235,7 +231,7 @@ async function doShowLessFrequently({ input, expected, keepViewOpen = false }) { } // Tests the "Not interested" result menu dismissal command. -add_task(async function resultMenu_notInterested() { +add_tasks_with_rust(async function resultMenu_notInterested() { await doDismissTest("not_interested"); // Re-enable suggestions and wait until PocketSuggestions syncs them from @@ -245,7 +241,7 @@ add_task(async function resultMenu_notInterested() { }); // Tests the "Not relevant" result menu dismissal command. -add_task(async function notRelevant() { +add_tasks_with_rust(async function notRelevant() { await doDismissTest("not_relevant"); }); @@ -361,7 +357,7 @@ async function doDismissTest(command) { } // Tests row labels. -add_task(async function rowLabel() { +add_tasks_with_rust(async function rowLabel() { const testCases = [ // high confidence keyword best match { @@ -389,7 +385,7 @@ add_task(async function rowLabel() { }); // Tests visibility of "Show less frequently" menu. -add_task(async function showLessFrequentlyMenuVisibility() { +add_tasks_with_rust(async function showLessFrequentlyMenuVisibility() { const testCases = [ // high confidence keyword best match { diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js index b7c2bdc25c..7197946171 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js @@ -401,6 +401,11 @@ async function doDismiss({ menu, assert }) { await UrlbarTestUtils.promisePopupClose(window); } +// Tests the "Manage" result menu. +add_task(async function resultMenu_manage() { + await doManageTest({ input: "ramen", index: 1 }); +}); + // Tests the row/group label. add_task(async function rowLabel() { let tests = [ diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js index 001c54458c..71c289e0ef 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js @@ -7,11 +7,6 @@ "use strict"; -ChromeUtils.defineESModuleGetters(this, { - CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.sys.mjs", -}); - const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; const MERINO_SUGGESTION = { diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js index 00cbe6c4e1..2c75b63a71 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js @@ -7,11 +7,6 @@ "use strict"; -ChromeUtils.defineESModuleGetters(this, { - CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.sys.mjs", -}); - const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; const MERINO_RESULT = { diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js index 821c5cf470..eab48faaaf 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js @@ -8,8 +8,6 @@ "use strict"; ChromeUtils.defineESModuleGetters(this, { - CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.sys.mjs", UrlbarView: "resource:///modules/UrlbarView.sys.mjs", sinon: "resource://testing-common/Sinon.sys.mjs", }); @@ -376,8 +374,11 @@ async function doEngagementWithoutAddingResultToView( let getPriorityStub = sandbox.stub(UrlbarProviderQuickSuggest, "getPriority"); getPriorityStub.returns(Infinity); - // Spy on `UrlbarProviderQuickSuggest.onEngagement()`. - let onEngagementSpy = sandbox.spy(UrlbarProviderQuickSuggest, "onEngagement"); + // Spy on `UrlbarProviderQuickSuggest.onLegacyEngagement()`. + let onLegacyEngagementSpy = sandbox.spy( + UrlbarProviderQuickSuggest, + "onLegacyEngagement" + ); let sandboxCleanup = () => { getPriorityStub?.restore(); @@ -454,7 +455,7 @@ async function doEngagementWithoutAddingResultToView( }); await loadPromise; - let engagementCalls = onEngagementSpy.getCalls().filter(call => { + let engagementCalls = onLegacyEngagementSpy.getCalls().filter(call => { let state = call.args[0]; return state == "engagement"; }); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js index 9a1aa06c02..f541801bae 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js @@ -7,11 +7,6 @@ "use strict"; -ChromeUtils.defineESModuleGetters(this, { - CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.sys.mjs", -}); - const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; const REMOTE_SETTINGS_RESULT = { diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js index 7c477e8af7..b11a491c92 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js @@ -7,11 +7,6 @@ "use strict"; -ChromeUtils.defineESModuleGetters(this, { - CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.sys.mjs", -}); - const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; const REMOTE_SETTINGS_RESULT = { diff --git a/browser/components/urlbar/tests/quicksuggest/browser/head.js b/browser/components/urlbar/tests/quicksuggest/browser/head.js index cc5f449e94..a1bf0feabe 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/head.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/head.js @@ -12,7 +12,7 @@ Services.scriptloader.loadSubScript( ChromeUtils.defineESModuleGetters(this, { CONTEXTUAL_SERVICES_PING_TYPES: - "resource:///modules/PartnerLinkAttribution.jsm", + "resource:///modules/PartnerLinkAttribution.sys.mjs", QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", UrlbarProviderQuickSuggest: @@ -522,6 +522,45 @@ async function doCommandTest({ info("Finished command test: " + JSON.stringify({ commandOrArray })); } +/* + * Do test the "Manage" result menu item. + * + * @param {object} options + * Options + * @param {number} options.index + * The index of the suggestion that will be checked in the results list. + * @param {number} options.input + * The input value on the urlbar. + */ +async function doManageTest({ index, input }) { + await BrowserTestUtils.withNewTab({ gBrowser }, async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + const managePage = "about:preferences#search"; + let onManagePageLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + managePage + ); + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem(window, "manage", { + resultIndex: index, + }); + await onManagePageLoaded; + + Assert.equal( + browser.currentURI.spec, + managePage, + "The manage page is loaded" + ); + + await UrlbarTestUtils.promisePopupClose(window); + }); +} + /** * Gets a row in the view, which is assumed to be open, and asserts that it's a * particular quick suggest row. If it is, the row is returned. If it's not, diff --git a/browser/components/urlbar/tests/quicksuggest/unit/head.js b/browser/components/urlbar/tests/quicksuggest/unit/head.js index 73bedf468e..5808e06bdf 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/head.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/head.js @@ -182,14 +182,11 @@ function makeWikipediaResult({ qsSuggestion: keyword, sponsoredAdvertiser: "Wikipedia", sponsoredIabCategory: "5 - Education", - helpUrl: QuickSuggest.HELP_URL, - helpL10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", - }, isBlockable: true, blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest", }, + isManageable: true, telemetryType: "adm_nonsponsored", }, }; @@ -256,14 +253,11 @@ function makeAmpResult({ sponsoredBlockId: blockId, sponsoredAdvertiser: advertiser, sponsoredIabCategory: iabCategory, - helpUrl: QuickSuggest.HELP_URL, - helpL10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", - }, isBlockable: true, blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest", }, + isManageable: true, telemetryType: "adm_sponsored", descriptionL10n: { id: "urlbar-result-action-sponsored" }, }, diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js index 1c00cb5320..ecb7c3dd09 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js @@ -3884,7 +3884,7 @@ async function checkSearch({ name, searchString, expectedResults }) { removeResult() {}, }, }); - UrlbarProviderQuickSuggest.onEngagement( + UrlbarProviderQuickSuggest.onLegacyEngagement( "engagement", context, { diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js index 61b1b9186f..c98fc5b6b4 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js @@ -149,7 +149,7 @@ add_task(async function canceledQueries() { }); function endEngagement({ controller, context = null, state = "engagement" }) { - UrlbarProviderQuickSuggest.onEngagement( + UrlbarProviderQuickSuggest.onLegacyEngagement( state, context || createContext("endEngagement", { diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js index cd794f435b..8479b97210 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js @@ -723,7 +723,7 @@ add_tasks_with_rust(async function block() { let result = context.results[0]; let provider = UrlbarProvidersManager.getProvider(result.providerName); Assert.ok(provider, "Sanity check: Result provider found"); - provider.onEngagement( + provider.onLegacyEngagement( "engagement", context, { diff --git a/browser/components/urlbar/tests/unit/test_exposure.js b/browser/components/urlbar/tests/unit/test_exposure.js index e3ce0b8479..3e63e668d7 100644 --- a/browser/components/urlbar/tests/unit/test_exposure.js +++ b/browser/components/urlbar/tests/unit/test_exposure.js @@ -3,7 +3,6 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ ChromeUtils.defineESModuleGetters(this, { - QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", UrlbarProviderQuickSuggest: "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", }); @@ -177,14 +176,11 @@ function makeAmpResult({ sponsoredBlockId: blockId, sponsoredAdvertiser: advertiser, sponsoredIabCategory: iabCategory, - helpUrl: QuickSuggest.HELP_URL, - helpL10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", - }, isBlockable: true, blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest", }, + isManageable: true, telemetryType: "adm_sponsored", descriptionL10n: { id: "urlbar-result-action-sponsored" }, }, @@ -240,14 +236,11 @@ function makeWikipediaResult({ qsSuggestion: keyword, sponsoredAdvertiser: "Wikipedia", sponsoredIabCategory: "5 - Education", - helpUrl: QuickSuggest.HELP_URL, - helpL10n: { - id: "urlbar-result-menu-learn-more-about-firefox-suggest", - }, isBlockable: true, blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest", }, + isManageable: true, telemetryType: "adm_nonsponsored", }, }; diff --git a/browser/components/urlbar/tests/unit/test_l10nCache.js b/browser/components/urlbar/tests/unit/test_l10nCache.js index e92c75fa01..bd93cc50d6 100644 --- a/browser/components/urlbar/tests/unit/test_l10nCache.js +++ b/browser/components/urlbar/tests/unit/test_l10nCache.js @@ -1,7 +1,7 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ -// Tests L10nCache in UrlbarUtils.jsm. +// Tests L10nCache in UrlbarUtils.sys.mjs. "use strict"; |