diff options
Diffstat (limited to '')
546 files changed, 31706 insertions, 7681 deletions
diff --git a/browser/components/BrowserContentHandler.sys.mjs b/browser/components/BrowserContentHandler.sys.mjs index 247f33c8b0..6c156d1700 100644 --- a/browser/components/BrowserContentHandler.sys.mjs +++ b/browser/components/BrowserContentHandler.sys.mjs @@ -60,62 +60,8 @@ function shouldLoadURI(aURI) { return false; } -function validateFirefoxProtocol(aCmdLine, launchedWithArg_osint) { - let paramCount = 0; - // Only accept one parameter when we're handling the protocol. - for (let i = 0; i < aCmdLine.length; i++) { - if (!aCmdLine.getArgument(i).startsWith("-")) { - paramCount++; - } - if (paramCount > 1) { - return false; - } - } - // `-osint` and handling registered file types and protocols is Windows-only. - return AppConstants.platform != "win" || launchedWithArg_osint; -} - -function resolveURIInternal( - aCmdLine, - aArgument, - launchedWithArg_osint = false -) { +function resolveURIInternal(aCmdLine, aArgument) { let principal = lazy.gSystemPrincipal; - - // If using Firefox protocol handler remove it from URI - // at this stage. This is before we would otherwise - // record telemetry so do that here. - let handleFirefoxProtocol = protocol => { - let protocolWithColon = protocol + ":"; - if (aArgument.startsWith(protocolWithColon)) { - if (!validateFirefoxProtocol(aCmdLine, launchedWithArg_osint)) { - throw new Error( - "Invalid use of Firefox-bridge and Firefox-private-bridge protocols." - ); - } - aArgument = aArgument.substring(protocolWithColon.length); - - if ( - !aArgument.startsWith("http://") && - !aArgument.startsWith("https://") - ) { - throw new Error( - "Firefox-bridge and Firefox-private-bridge protocols can only be used in conjunction with http and https urls." - ); - } - - principal = Services.scriptSecurityManager.createNullPrincipal({}); - Services.telemetry.keyedScalarAdd( - "os.environment.launched_to_handle", - protocol, - 1 - ); - } - }; - - handleFirefoxProtocol("firefox-bridge"); - handleFirefoxProtocol("firefox-private-bridge"); - var uri = aCmdLine.resolveURI(aArgument); var uriFixup = Services.uriFixup; @@ -376,12 +322,7 @@ function openBrowserWindow( Ci.nsILoadContext ).usePrivateBrowsing = true; - if ( - AppConstants.platform == "win" && - lazy.NimbusFeatures.majorRelease2022.getVariable( - "feltPrivacyWindowSeparation" - ) - ) { + if (AppConstants.platform == "win") { lazy.WinTaskbar.setGroupIdForWindow( win, lazy.WinTaskbar.defaultPrivateGroupId @@ -602,17 +543,7 @@ nsBrowserContentHandler.prototype = { "private-window", false ); - // Check for Firefox private browsing protocol handler here. - let url = null; - let urlFlagIdx = cmdLine.findFlag("url", false); - if (urlFlagIdx > -1 && cmdLine.length > 1) { - url = cmdLine.getArgument(urlFlagIdx + 1); - } - if (privateWindowParam || url?.startsWith("firefox-private-bridge:")) { - // Check if the osint flag is present on Windows - let launchedWithArg_osint = - AppConstants.platform == "win" && - cmdLine.findFlag("osint", false) == 0; + if (privateWindowParam) { let forcePrivate = true; let resolvedInfo; if (!lazy.PrivateBrowsingUtils.enabled) { @@ -623,19 +554,8 @@ nsBrowserContentHandler.prototype = { uri: Services.io.newURI("about:privatebrowsing"), principal: lazy.gSystemPrincipal, }; - } else if (url?.startsWith("firefox-private-bridge:")) { - cmdLine.removeArguments(urlFlagIdx, urlFlagIdx + 1); - resolvedInfo = resolveURIInternal( - cmdLine, - url, - launchedWithArg_osint - ); } else { - resolvedInfo = resolveURIInternal( - cmdLine, - privateWindowParam, - launchedWithArg_osint - ); + resolvedInfo = resolveURIInternal(cmdLine, privateWindowParam); } handURIToExistingBrowser( resolvedInfo.uri, @@ -1430,11 +1350,7 @@ nsDefaultCommandLineHandler.prototype = { try { var ar; while ((ar = cmdLine.handleFlagWithParam("url", false))) { - let { uri, principal } = resolveURIInternal( - cmdLine, - ar, - launchedWithArg_osint - ); + let { uri, principal } = resolveURIInternal(cmdLine, ar); urilist.push(uri); principalList.push(principal); @@ -1506,9 +1422,6 @@ nsDefaultCommandLineHandler.prototype = { } // Can't open multiple URLs without using system principal. - // The firefox-bridge and firefox-private-bridge protocols should only - // accept a single URL due to using the -osint option - // so this isn't very relevant. var URLlist = urilist.filter(shouldLoadURI).map(u => u.spec); if (URLlist.length) { openBrowserWindow(cmdLine, lazy.gSystemPrincipal, URLlist); diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index 52f4a77d82..b6ae665df0 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -24,6 +24,7 @@ ChromeUtils.defineESModuleGetters(lazy, { BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs", + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", @@ -42,11 +43,13 @@ ChromeUtils.defineESModuleGetters(lazy, { FeatureGate: "resource://featuregates/FeatureGate.sys.mjs", FirefoxBridgeExtensionUtils: "resource:///modules/FirefoxBridgeExtensionUtils.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", HomePage: "resource:///modules/HomePage.sys.mjs", Integration: "resource://gre/modules/Integration.sys.mjs", Interactions: "resource:///modules/Interactions.sys.mjs", LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", NetUtil: "resource://gre/modules/NetUtil.sys.mjs", NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", @@ -124,11 +127,7 @@ XPCOMUtils.defineLazyServiceGetters(lazy, { ChromeUtils.defineLazyGetter( lazy, "accountsL10n", - () => - new Localization( - ["browser/accounts.ftl", "toolkit/branding/accounts.ftl"], - true - ) + () => new Localization(["browser/accounts.ftl"], true) ); if (AppConstants.ENABLE_WEBDRIVER) { @@ -426,6 +425,20 @@ let JSWINDOWACTORS = { enablePreference: "browser.aboutwelcome.enabled", }, + BackupUI: { + parent: { + esModuleURI: "resource:///actors/BackupUIParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/BackupUIChild.sys.mjs", + events: { + "BackupUI:InitWidget": { wantUntrusted: true }, + }, + }, + matches: ["about:preferences*", "about:settings*"], + enablePreference: "browser.backup.preferences.ui.enabled", + }, + BlockedSite: { parent: { esModuleURI: "resource:///actors/BlockedSiteParent.sys.mjs", @@ -736,6 +749,7 @@ let JSWINDOWACTORS = { "Screenshots:OverlaySelection": {}, "Screenshots:RecordEvent": {}, "Screenshots:ShowPanel": {}, + "Screenshots:FocusPanel": {}, }, }, enablePreference: "screenshots.browser.component.enabled", @@ -1149,6 +1163,9 @@ BrowserGlue.prototype = { case "fxaccounts:commands:open-uri": this._onDisplaySyncURIs(subject); break; + case "fxaccounts:commands:close-uri": + this._onIncomingCloseTabCommand(subject); + break; case "session-save": this._setPrefToSaveSession(true); subject.QueryInterface(Ci.nsISupportsPRBool); @@ -1198,13 +1215,13 @@ BrowserGlue.prototype = { case "initial-migration-did-import-default-bookmarks": this._initPlaces(true); break; - case "handle-xul-text-link": + case "handle-xul-text-link": { let linkHandled = subject.QueryInterface(Ci.nsISupportsPRBool); if (!linkHandled.data) { let win = lazy.BrowserWindowTracker.getTopWindow(); if (win) { data = JSON.parse(data); - let where = win.whereToOpenLink(data); + let where = lazy.BrowserUtils.whereToOpenLink(data); // Preserve legacy behavior of non-modifier left-clicks // opening in a new selected tab. if (where == "current") { @@ -1215,13 +1232,14 @@ BrowserGlue.prototype = { } } break; + } case "profile-before-change": // Any component depending on Places should be finalized in // _onPlacesShutdown. Any component that doesn't need to act after // the UI has gone should be finalized in _onQuitApplicationGranted. this._dispose(); break; - case "keyword-search": + case "keyword-search": { // This notification is broadcast by the docshell when it "fixes up" a // URI that it's been asked to load into a keyword search. let engine = null; @@ -1239,13 +1257,15 @@ BrowserGlue.prototype = { "urlbar" ); break; - case "xpi-signature-changed": + } + case "xpi-signature-changed": { let disabledAddons = JSON.parse(data).disabled; let addons = await lazy.AddonManager.getAddonsByIDs(disabledAddons); if (addons.some(addon => addon)) { this._notifyUnsignedAddonsDisabled(); } break; + } case "sync-ui-state:update": this._updateFxaBadges(lazy.BrowserWindowTracker.getTopWindow()); break; @@ -1263,7 +1283,7 @@ BrowserGlue.prototype = { lazy.DownloadsViewableInternally.register(); break; - case "app-startup": + case "app-startup": { this._earlyBlankFirstPaint(subject); gThisInstanceIsTaskbarTab = subject.handleFlag("taskbar-tab", false); gThisInstanceIsLaunchOnLogin = subject.handleFlag( @@ -1297,6 +1317,7 @@ BrowserGlue.prototype = { await lazy.WindowsLaunchOnLogin.removeLaunchOnLoginRegistryKey(); } break; + } } }, @@ -1316,6 +1337,7 @@ BrowserGlue.prototype = { "fxaccounts:verify_login", "fxaccounts:device_disconnected", "fxaccounts:commands:open-uri", + "fxaccounts:commands:close-uri", "session-save", "places-init-complete", "distribution-customization-complete", @@ -1446,6 +1468,17 @@ BrowserGlue.prototype = { lazy.PdfJs.checkIsDefault(this._isNewProfile); } + if (!AppConstants.NIGHTLY_BUILD && this._isNewProfile) { + lazy.FormAutofillUtils.setOSAuthEnabled( + lazy.FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + false + ); + lazy.LoginHelper.setOSAuthEnabled( + lazy.LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF, + false + ); + } + listeners.init(); lazy.SessionStore.init(); @@ -1630,7 +1663,9 @@ BrowserGlue.prototype = { "unsignedAddonsDisabled.learnMore.accesskey" ), callback() { - win.BrowserOpenAddonsMgr("addons://list/extension?unsigned=true"); + win.BrowserAddonUI.openAddonsMgr( + "addons://list/extension?unsigned=true" + ); }, }, ]; @@ -2708,12 +2743,6 @@ BrowserGlue.prototype = { name: "ensurePrivateBrowsingShortcutExists", condition: AppConstants.platform == "win" && - // Pref'ed off until Private Browsing window separation is enabled by default - // to avoid a situation where a user pins the Private Browsing shortcut to - // the Taskbar, which will end up launching into a different Taskbar icon. - lazy.NimbusFeatures.majorRelease2022.getVariable( - "feltPrivacyWindowSeparation" - ) && // We don't want a shortcut if it's been disabled, eg: by enterprise policy. lazy.PrivateBrowsingUtils.enabled && // Private Browsing shortcuts for packaged builds come with the package, @@ -2935,7 +2964,7 @@ BrowserGlue.prototype = { let cfg = lazy.NimbusFeatures.gleanInternalSdk.getVariable( "gleanMetricConfiguration" ); - Services.fog.setMetricsFeatureConfig(JSON.stringify(cfg)); + Services.fog.applyServerKnobsConfig(JSON.stringify(cfg)); }); // Register Glean to listen for experiment updates releated to the @@ -2944,7 +2973,7 @@ BrowserGlue.prototype = { let cfg = lazy.NimbusFeatures.glean.getVariable( "gleanMetricConfiguration" ); - Services.fog.setMetricsFeatureConfig(JSON.stringify(cfg)); + Services.fog.applyServerKnobsConfig(JSON.stringify(cfg)); }); }, }, @@ -3086,11 +3115,9 @@ BrowserGlue.prototype = { { name: "DAPTelemetrySender.startup", - condition: - lazy.TelemetryUtils.isTelemetryEnabled && - lazy.NimbusFeatures.dapTelemetry.getVariable("enabled"), - task: () => { - lazy.DAPTelemetrySender.startup(); + condition: lazy.TelemetryUtils.isTelemetryEnabled, + task: async () => { + await lazy.DAPTelemetrySender.startup(); }, }, @@ -3243,6 +3270,14 @@ BrowserGlue.prototype = { function reportInstallationTelemetry() { lazy.BrowserUsageTelemetry.reportInstallationTelemetry(); }, + + function trustObjectTelemetry() { + let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + // countTrustObjects also logs the number of trust objects for telemetry purposes + certdb.countTrustObjects(); + }, ]; for (let task of idleTasks) { @@ -3724,7 +3759,7 @@ BrowserGlue.prototype = { _onThisDeviceConnected() { const [title, body] = lazy.accountsL10n.formatValuesSync([ - "account-connection-title", + "account-connection-title-2", "account-connection-connected", ]); @@ -3769,7 +3804,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 = 144; + const UI_VERSION = 147; const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; if (!Services.prefs.prefHasUserValue("browser.migration.version")) { @@ -3777,12 +3812,6 @@ BrowserGlue.prototype = { Services.prefs.setIntPref("browser.migration.version", UI_VERSION); this._isNewProfile = true; - if (AppConstants.platform == "win") { - // Ensure that the Firefox Bridge protocols are registered for the new profile. - // No-op if they are registered for the user or the local machine already. - lazy.FirefoxBridgeExtensionUtils.maybeRegisterFirefoxBridgeProtocols(); - } - return; } @@ -4383,24 +4412,7 @@ BrowserGlue.prototype = { } if (currentUIVersion < 143) { - if (AppConstants.platform == "win") { - // In Firefox 122, we enabled the firefox and firefox-private protocols. - // We switched over to using firefox-bridge and firefox-private-bridge, - // but we want to clean up the use of the other protocols. - lazy.FirefoxBridgeExtensionUtils.maybeDeleteBridgeProtocolRegistryEntries(); - - // Register the new firefox bridge related protocols now - lazy.FirefoxBridgeExtensionUtils.maybeRegisterFirefoxBridgeProtocols(); - - // Clean up the old user prefs from FX 122 - Services.prefs.clearUserPref( - "network.protocol-handler.external.firefox" - ); - Services.prefs.clearUserPref( - "network.protocol-handler.external.firefox-private" - ); - Services.prefs.clearUserPref("browser.shell.customProtocolsRegistered"); - } + // Version 143 has been superseded by version 145 below. } if (currentUIVersion < 144) { @@ -4420,6 +4432,99 @@ BrowserGlue.prototype = { } } + if (currentUIVersion < 145) { + if (AppConstants.platform == "win") { + // In Firefox 122, we enabled the firefox and firefox-private protocols. + // We switched over to using firefox-bridge and firefox-private-bridge, + // but we want to clean up the use of the other protocols. + lazy.FirefoxBridgeExtensionUtils.maybeDeleteBridgeProtocolRegistryEntries( + lazy.FirefoxBridgeExtensionUtils.OLD_PUBLIC_PROTOCOL, + lazy.FirefoxBridgeExtensionUtils.OLD_PRIVATE_PROTOCOL + ); + + // Clean up the old user prefs from FX 122 + Services.prefs.clearUserPref( + "network.protocol-handler.external.firefox" + ); + Services.prefs.clearUserPref( + "network.protocol-handler.external.firefox-private" + ); + + // In Firefox 126, we switched over to using native messaging so the + // protocols are no longer necessary even in firefox-bridge and + // firefox-private-bridge form + lazy.FirefoxBridgeExtensionUtils.maybeDeleteBridgeProtocolRegistryEntries( + lazy.FirefoxBridgeExtensionUtils.PUBLIC_PROTOCOL, + lazy.FirefoxBridgeExtensionUtils.PRIVATE_PROTOCOL + ); + Services.prefs.clearUserPref( + "network.protocol-handler.external.firefox-bridge" + ); + Services.prefs.clearUserPref( + "network.protocol-handler.external.firefox-private-bridge" + ); + Services.prefs.clearUserPref("browser.shell.customProtocolsRegistered"); + } + } + + // Version 146 had a typo issue and thus it has been replaced by 147. + + if (currentUIVersion < 147) { + // We're securing the boolean prefs for OS Authentication. + // This is achieved by converting them into a string pref and encrypting the values + // stored inside it. + + if (!AppConstants.NIGHTLY_BUILD) { + const hasRunBetaMigration = Services.prefs + .getCharPref("browser.startup.homepage_override.mstone", "") + .startsWith("127.0"); + + // Version 146 UI migration wrote to a wrong `creditcards` pref when + // the feature was disabled, instead it should have used `creditCards`. + // The correct pref name is in AUTOFILL_CREDITCARDS_REAUTH_PREF. + // Note that we only wrote prefs if the feature was disabled. + let ccTypoDisabled = !lazy.FormAutofillUtils.getOSAuthEnabled( + "extensions.formautofill.creditcards.reauth.optout" + ); + let ccCorrectPrefDisabled = !lazy.FormAutofillUtils.getOSAuthEnabled( + lazy.FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ); + let ccPrevReauthPrefValue = Services.prefs.getBoolPref( + "extensions.formautofill.reauth.enabled", + false + ); + + let userHadEnabledCreditCardReauth = + // If we've run beta migration, and neither typo nor correct pref + // indicate disablement, the user enabled the pref: + (hasRunBetaMigration && !ccTypoDisabled && !ccCorrectPrefDisabled) || + // Or if we never ran beta migration and the bool pref is set: + ccPrevReauthPrefValue; + + lazy.FormAutofillUtils.setOSAuthEnabled( + lazy.FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + userHadEnabledCreditCardReauth + ); + + if (!hasRunBetaMigration) { + const passwordsPrevReauthPrefValue = Services.prefs.getBoolPref( + "signon.management.page.os-auth.enabled", + false + ); + lazy.LoginHelper.setOSAuthEnabled( + lazy.LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF, + passwordsPrevReauthPrefValue + ); + } + } + + Services.prefs.clearUserPref("extensions.formautofill.reauth.enabled"); + Services.prefs.clearUserPref("signon.management.page.os-auth.enabled"); + Services.prefs.clearUserPref( + "extensions.formautofill.creditcards.reauth.optout" + ); + } + // Update the migration version. Services.prefs.setIntPref("browser.migration.version", UI_VERSION); }, @@ -4515,10 +4620,7 @@ BrowserGlue.prototype = { return "disallow-postUpdate"; } - const useMROnboarding = - lazy.NimbusFeatures.majorRelease2022.getVariable("onboarding"); const showUpgradeDialog = - useMROnboarding ?? lazy.NimbusFeatures.upgradeDialog.getVariable("enabled"); return showUpgradeDialog ? "" : "disabled"; @@ -4774,6 +4876,32 @@ BrowserGlue.prototype = { } }, + async _onIncomingCloseTabCommand(data) { + // The payload is wrapped weirdly because of how Sync does notifications. + const wrappedObj = data.wrappedJSObject.object; + let { urls } = wrappedObj[0]; + let urisToClose = []; + urls.forEach(urlString => { + try { + urisToClose.push(Services.io.newURI(urlString)); + } catch (ex) { + // The url was invalid so we ignore + console.error(ex); + } + }); + for (let win of lazy.BrowserWindowTracker.orderedWindows) { + // Ensure we're operating on fully opened browser windows + if (!win.gBrowser) { + continue; + } + urisToClose = await win.gBrowser.closeTabsByURI(urisToClose); + // If we've successfully closed all the tabs, break early + if (!urisToClose.length) { + break; + } + } + }, + async _onVerifyLoginNotification({ body, title, url }) { let tab; let imageURL; @@ -4812,7 +4940,7 @@ BrowserGlue.prototype = { _onDeviceConnected(deviceName) { const [title, body] = lazy.accountsL10n.formatValuesSync([ - { id: "account-connection-title" }, + { id: "account-connection-title-2" }, deviceName ? { id: "account-connection-connected-with", args: { deviceName } } : { id: "account-connection-connected-with-noname" }, @@ -4849,7 +4977,7 @@ BrowserGlue.prototype = { _onDeviceDisconnected() { const [title, body] = lazy.accountsL10n.formatValuesSync([ - "account-connection-title", + "account-connection-title-2", "account-connection-disconnected", ]); @@ -5732,7 +5860,9 @@ export var AboutHomeStartupCache = { this.setDeferredResult(this.CACHE_RESULT_SCALARS.UNSET); - this._enabled = !!lazy.NimbusFeatures.abouthomecache.getVariable("enabled"); + this._enabled = Services.prefs.getBoolPref( + "browser.startup.homepage.abouthome_cache.enabled" + ); if (!this._enabled) { this.recordResult(this.CACHE_RESULT_SCALARS.DISABLED); diff --git a/browser/components/StartupRecorder.sys.mjs b/browser/components/StartupRecorder.sys.mjs index 7b69b52690..bdc08ee6dd 100644 --- a/browser/components/StartupRecorder.sys.mjs +++ b/browser/components/StartupRecorder.sys.mjs @@ -6,6 +6,23 @@ const Cm = Components.manager; Cm.QueryInterface(Ci.nsIServiceManager); import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "BROWSER_STARTUP_RECORD", + "browser.startup.record", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "BROWSER_STARTUP_RECORD_IMAGES", + "browser.startup.recordImages", + false +); let firstPaintNotification = "widget-first-paint"; // widget-first-paint fires much later than expected on Linux. @@ -98,10 +115,7 @@ StartupRecorder.prototype = { return; } - if ( - !Services.prefs.getBoolPref("browser.startup.record", false) && - !Services.prefs.getBoolPref("browser.startup.recordImages", false) - ) { + if (!lazy.BROWSER_STARTUP_RECORD && !lazy.BROWSER_STARTUP_RECORD_IMAGES) { this._resolve(); this._resolve = null; return; @@ -118,7 +132,7 @@ StartupRecorder.prototype = { "browser-startup-idle-tasks-finished", ]; - if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) { + if (lazy.BROWSER_STARTUP_RECORD_IMAGES) { // For code simplicify, recording images excludes the other startup // recorder behaviors, so we can observe only the image topics. topics = [ @@ -180,7 +194,7 @@ StartupRecorder.prototype = { this.record.bind(this, "before handling user events") ); } else if (topic == "browser-startup-idle-tasks-finished") { - if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) { + if (lazy.BROWSER_STARTUP_RECORD_IMAGES) { Services.obs.removeObserver(this, "image-drawing"); Services.obs.removeObserver(this, "image-loading"); this._resolve(); diff --git a/browser/components/aboutlogins/AboutLoginsChild.sys.mjs b/browser/components/aboutlogins/AboutLoginsChild.sys.mjs index 3fcdf77923..c7059d8f40 100644 --- a/browser/components/aboutlogins/AboutLoginsChild.sys.mjs +++ b/browser/components/aboutlogins/AboutLoginsChild.sys.mjs @@ -160,7 +160,11 @@ export class AboutLoginsChild extends JSWindowActorChild { } #aboutLoginsCopyLoginDetail(detail) { - lazy.ClipboardHelper.copyString(detail, lazy.ClipboardHelper.Sensitive); + lazy.ClipboardHelper.copyString( + detail, + this.windowContext, + lazy.ClipboardHelper.Sensitive + ); } #aboutLoginsCreateLogin(login) { diff --git a/browser/components/aboutlogins/AboutLoginsParent.sys.mjs b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs index 28f56c2172..4342e60296 100644 --- a/browser/components/aboutlogins/AboutLoginsParent.sys.mjs +++ b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs @@ -17,7 +17,6 @@ ChromeUtils.defineESModuleGetters(lazy, { LoginExport: "resource://gre/modules/LoginExport.sys.mjs", LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", - OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", UIState: "resource://services-sync/UIState.sys.mjs", }); @@ -38,12 +37,6 @@ XPCOMUtils.defineLazyPreferenceGetter( ); XPCOMUtils.defineLazyPreferenceGetter( lazy, - "OS_AUTH_ENABLED", - "signon.management.page.os-auth.enabled", - true -); -XPCOMUtils.defineLazyPreferenceGetter( - lazy, "VULNERABLE_PASSWORDS_ENABLED", "signon.management.page.vulnerable-passwords.enabled", false @@ -266,11 +259,15 @@ export class AboutLoginsParent extends JSWindowActorParent { let messageText = { value: "NOT SUPPORTED" }; let captionText = { value: "" }; + const isOSAuthEnabled = lazy.LoginHelper.getOSAuthEnabled( + lazy.LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF + ); + // This feature is only supported on Windows and macOS // but we still call in to OSKeyStore on Linux to get // the proper auth_details for Telemetry. // See bug 1614874 for Linux support. - if (lazy.OS_AUTH_ENABLED && lazy.OSKeyStore.canReauth()) { + if (isOSAuthEnabled) { messageId += "-" + AppConstants.platform; [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([ { @@ -284,7 +281,7 @@ export class AboutLoginsParent extends JSWindowActorParent { let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth( this.browsingContext.embedderElement, - lazy.OS_AUTH_ENABLED, + isOSAuthEnabled, AboutLogins._authExpirationTime, messageText.value, captionText.value @@ -378,11 +375,15 @@ export class AboutLoginsParent extends JSWindowActorParent { let messageText = { value: "NOT SUPPORTED" }; let captionText = { value: "" }; + const isOSAuthEnabled = lazy.LoginHelper.getOSAuthEnabled( + lazy.LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF + ); + // This feature is only supported on Windows and macOS // but we still call in to OSKeyStore on Linux to get // the proper auth_details for Telemetry. // See bug 1614874 for Linux support. - if (lazy.OSKeyStore.canReauth()) { + if (isOSAuthEnabled) { const messageId = EXPORT_PASSWORD_OS_AUTH_DIALOG_MESSAGE_IDS[AppConstants.platform]; if (!messageId) { diff --git a/browser/components/aboutlogins/LoginBreaches.sys.mjs b/browser/components/aboutlogins/LoginBreaches.sys.mjs index b2a0af5e39..496e5de575 100644 --- a/browser/components/aboutlogins/LoginBreaches.sys.mjs +++ b/browser/components/aboutlogins/LoginBreaches.sys.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/. */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + /** * Manages breach alerts for saved logins using data from Firefox Monitor via * RemoteSettings. @@ -16,6 +18,13 @@ ChromeUtils.defineESModuleGetters(lazy, { "resource://services-settings/RemoteSettingsClient.sys.mjs", }); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "VULNERABLE_PASSWORDS_ENABLED", + "signon.management.page.vulnerable-passwords.enabled", + false +); + export const LoginBreaches = { REMOTE_SETTINGS_COLLECTION: "fxmonitor-breaches", @@ -138,6 +147,20 @@ export const LoginBreaches = { return vulnerablePasswordsByLoginGUID; }, + recordBreachAlertDismissal(loginGuid) { + const storageJSON = Services.logins.wrappedJSObject._storage; + return storageJSON.recordBreachAlertDismissal(loginGuid); + }, + + isVulnerablePassword(login) { + if (!lazy.VULNERABLE_PASSWORDS_ENABLED) { + return false; + } + + const storageJSON = Services.logins.wrappedJSObject._storage; + return storageJSON.isPotentiallyVulnerablePassword(login); + }, + async clearAllPotentiallyVulnerablePasswords() { await Services.logins.initializationPromise; const storageJSON = Services.logins.wrappedJSObject._storage; diff --git a/browser/components/aboutlogins/content/aboutLogins.html b/browser/components/aboutlogins/content/aboutLogins.html index 67712c8f29..a0c04149e7 100644 --- a/browser/components/aboutlogins/content/aboutLogins.html +++ b/browser/components/aboutlogins/content/aboutLogins.html @@ -11,7 +11,6 @@ <title data-l10n-id="about-logins-page-title-name"></title> <link rel="localization" href="branding/brand.ftl"> <link rel="localization" href="browser/aboutLogins.ftl"> - <link rel="localization" href="toolkit/branding/accounts.ftl"> <link rel="localization" href="toolkit/branding/brandings.ftl"> <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script> <script type="module" src="chrome://browser/content/aboutlogins/components/remove-logins-dialog.mjs"></script> diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.html b/browser/components/aboutlogins/content/aboutLoginsImportReport.html index 9ab2641ca2..c9956e2cca 100644 --- a/browser/components/aboutlogins/content/aboutLoginsImportReport.html +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.html @@ -14,7 +14,6 @@ <title data-l10n-id="about-logins-import-report-page-title"></title> <link rel="localization" href="branding/brand.ftl" /> <link rel="localization" href="browser/aboutLogins.ftl" /> - <link rel="localization" href="toolkit/branding/accounts.ftl" /> <link rel="localization" href="toolkit/branding/brandings.ftl" /> <script type="module" diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css index 4a3d85d859..e9e91f78ed 100644 --- a/browser/components/aboutlogins/content/components/login-item.css +++ b/browser/components/aboutlogins/content/components/login-item.css @@ -64,11 +64,6 @@ form { display: none; } -input[type="password"], -input[type="text"], -input[type="url"] { - text-align: match-parent !important; /* override `all: unset` in the rule below */ -} :host(:not([data-editing])) input[type="password"]:read-only, input[type="text"]:read-only, @@ -82,6 +77,17 @@ input[type="url"]:read-only { width: 100%; } +input:is([type="password"], [type="text"], [type="url"]) { + /* Override all: unset above */ + appearance: textfield !important; + text-align: match-parent !important; +} + +input.password-display, +input[name="password"] { + font-family: monospace !important; /* Override all: unset above */ +} + /* We can't use `margin-inline-start` here because we force * the input to have dir="ltr", so we set the margin manually * using the parent element's directionality. */ @@ -197,11 +203,6 @@ moz-button-group, box-shadow: none; } -input.password-display, -input[name="password"] { - font-family: monospace !important; /* override `all: unset` in the rule above */ -} - .reveal-password-checkbox { appearance: none; background-image: url("resource://gre-resources/password.svg"); diff --git a/browser/components/aboutlogins/tests/browser/browser.toml b/browser/components/aboutlogins/tests/browser/browser.toml index 0b38e0dda1..07c49c4c88 100644 --- a/browser/components/aboutlogins/tests/browser/browser.toml +++ b/browser/components/aboutlogins/tests/browser/browser.toml @@ -2,7 +2,6 @@ support-files = ["head.js"] prefs = [ "signon.management.page.vulnerable-passwords.enabled=true", - "signon.management.page.os-auth.enabled=true", "toolkit.telemetry.ipcBatchTimeout=10", # lower the interval for event telemetry in the content process to update the parent process ] # Run first so content events from previous tests won't trickle in. diff --git a/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js index 52b2eb02a2..4f3cdbe48e 100644 --- a/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js +++ b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js @@ -72,7 +72,10 @@ add_task(async function test_telemetry_events() { await LoginTestUtils.telemetry.waitForEventCount(3); if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { - let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); let copyButton = loginItem.shadowRoot.querySelector( @@ -106,9 +109,12 @@ add_task(async function test_telemetry_events() { // Show the password if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { - let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } nextTelemetryEventCount++; // An extra event is observed for the reauth event. await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); diff --git a/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js index b2b036121a..b28dcf25ee 100644 --- a/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js +++ b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js @@ -32,6 +32,7 @@ add_setup(async function () { gBrowser, url: "about:logins", }); + registerCleanupFunction(() => { BrowserTestUtils.removeTab(gBrowser.selectedTab); Services.logins.removeAllUserFacingLogins(); @@ -104,9 +105,13 @@ add_task(async function test_added_login_shows_breach_warning() { return; } - let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } + // Change the password on the breached login and check that the // login is no longer marked as breached. The vulnerable login // should still be marked as vulnerable afterwards. diff --git a/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js index ee1527d792..55180435c7 100644 --- a/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js +++ b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js @@ -32,8 +32,10 @@ if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { gTests[gTests.length] = { name: "test contextmenu on password field in edit login view", async setup(browser) { - let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); - + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } // load up the edit login view await SpecialPowers.spawn( browser, diff --git a/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js index 8b0d3b7bf6..1f6eff8806 100644 --- a/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js +++ b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js @@ -49,9 +49,11 @@ add_task(async function test() { info( "waiting for " + testObj.expectedValue + " to be placed on clipboard" ); - let reauthObserved = true; + let reauthObserved = Promise.resolve(); if (testObj.copyButtonSelector.includes("password")) { - reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } } await SimpleTest.promiseClipboardChange( diff --git a/browser/components/aboutlogins/tests/browser/browser_createLogin.js b/browser/components/aboutlogins/tests/browser/browser_createLogin.js index 4aac8cece1..46c4487ba3 100644 --- a/browser/components/aboutlogins/tests/browser/browser_createLogin.js +++ b/browser/components/aboutlogins/tests/browser/browser_createLogin.js @@ -232,9 +232,12 @@ add_task(async function test_create_login() { continue; } - let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays( content.document.querySelector("login-item") diff --git a/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js index 90195d0e0a..4fc250523c 100644 --- a/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js +++ b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js @@ -71,7 +71,10 @@ add_task(async function test_login_item() { }, "Waiting for login item to get populated"); Assert.ok(loginItemPopulated, "The login item should get populated"); }); - let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays( content.document.querySelector("login-item") diff --git a/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js index a78f49d3c9..1789d47b8e 100644 --- a/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js +++ b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js @@ -107,7 +107,10 @@ add_task(async function test_showLoginItemErrors() { // The rest of the test uses Edit mode which causes an OS prompt in official builds. return; } - let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn( browser, [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], diff --git a/browser/components/aboutlogins/tests/browser/browser_openExport.js b/browser/components/aboutlogins/tests/browser/browser_openExport.js index 1a61510862..f4f7761259 100644 --- a/browser/components/aboutlogins/tests/browser/browser_openExport.js +++ b/browser/components/aboutlogins/tests/browser/browser_openExport.js @@ -8,9 +8,6 @@ * Test the export logins file picker appears. */ -let { OSKeyStore } = ChromeUtils.importESModule( - "resource://gre/modules/OSKeyStore.sys.mjs" -); let { TelemetryTestUtils } = ChromeUtils.importESModule( "resource://testing-common/TelemetryTestUtils.sys.mjs" ); diff --git a/browser/components/aboutlogins/tests/browser/browser_openSite.js b/browser/components/aboutlogins/tests/browser/browser_openSite.js index f33d57a8e4..c3fc8d5cd1 100644 --- a/browser/components/aboutlogins/tests/browser/browser_openSite.js +++ b/browser/components/aboutlogins/tests/browser/browser_openSite.js @@ -44,7 +44,10 @@ add_task(async function test_launch_login_item() { gBrowser, TEST_LOGIN1.origin + "/" ); - let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); loginItem._editButton.click(); diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js index 9c2688cc77..21a5eeda12 100644 --- a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js +++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js @@ -1,12 +1,105 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ -add_task(async function test() { - info( - `updatechannel: ${UpdateUtils.getUpdateChannel(false)}; platform: ${ - AppConstants.platform - }` +"use strict"; + +// On mac, this test times out in chaos mode +requestLongerTimeout(2); + +const PAGE_PREFS = "about:preferences"; +const PAGE_PRIVACY = PAGE_PREFS + "#privacy"; +const SELECTORS = { + reauthCheckbox: "#osReauthCheckbox", +}; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + // Undo mocking from head.js + sinon.restore(); +}); + +add_task(async function test_os_auth_enabled_with_checkbox() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + + await SpecialPowers.spawn( + browser, + [SELECTORS, AppConstants.NIGHTLY_BUILD], + async (selectors, isNightly) => { + is( + content.document.querySelector(selectors.reauthCheckbox).checked, + isNightly, + "OSReauth for Passwords should be checked" + ); + } + ); + is( + LoginHelper.getOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF), + AppConstants.NIGHTLY_BUILD, + "OSAuth should be enabled." + ); + } ); +}); + +add_task(async function test_os_auth_disabled_with_checkbox() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + LoginHelper.setOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF, false); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + + await SpecialPowers.spawn(browser, [SELECTORS], async selectors => { + is( + content.document.querySelector(selectors.reauthCheckbox).checked, + false, + "OSReauth for passwords should be unchecked" + ); + }); + is( + LoginHelper.getOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF), + false, + "OSAuth should be disabled" + ); + } + ); + LoginHelper.setOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF, true); +}); + +add_task(async function test_OSAuth_enabled_with_random_value_in_pref() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + await SpecialPowers.pushPrefEnv({ + set: [[PASSWORDS_OS_REAUTH_PREF, "poutine-gravy"]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], async selectors => { + let reauthCheckbox = content.document.querySelector( + selectors.reauthCheckbox + ); + is( + reauthCheckbox.checked, + true, + "OSReauth for passwords should be checked" + ); + }); + is( + LoginHelper.getOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF), + true, + "OSAuth should be enabled since the pref does not decrypt to 'opt out'." + ); + } + ); +}); + +add_task(async function test_osAuth_shown_on_edit_login() { if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { Assert.ok( true, @@ -14,41 +107,51 @@ add_task(async function test() { ); return; } - - TEST_LOGIN1 = await addLogin(TEST_LOGIN1); - await BrowserTestUtils.openNewForegroundTab({ gBrowser, url: "about:logins", }); - - registerCleanupFunction(function () { - Services.logins.removeAllUserFacingLogins(); - BrowserTestUtils.removeTab(gBrowser.selectedTab); - }); - - // Show OS auth dialog when Reveal Password checkbox is checked if not on a new login - let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); - let revealCheckbox = loginItem.shadowRoot.querySelector( - ".reveal-password-checkbox" + Assert.ok( + !loginItem.dataset.editing, + "Not in edit mode before clicking 'Edit'" ); - revealCheckbox.click(); + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); }); + await osAuthDialogShown; - info("OS auth dialog shown and canceled"); - await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { - let loginItem = content.document.querySelector("login-item"); - let revealCheckbox = loginItem.shadowRoot.querySelector( - ".reveal-password-checkbox" + info("OS auth dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("login-item").dataset.editing, + "login item should be in 'edit' mode" ); + }); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_osAuth_shown_on_reveal_password() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { Assert.ok( - !revealCheckbox.checked, - "reveal checkbox should be unchecked if OS auth dialog canceled" + true, + `skipping test since oskeystore cannot be automated in this environment` ); + return; + } + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", }); - osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); let revealCheckbox = loginItem.shadowRoot.querySelector( @@ -68,10 +171,70 @@ add_task(async function test() { "reveal checkbox should be checked if OS auth dialog authenticated" ); }); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); - info("'Edit' shouldn't show the prompt since the user has authenticated now"); +add_task(async function test_osAuth_shown_on_copy_password() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); + let copyPassword = loginItem.shadowRoot.querySelector( + "copy-password-button" + ); + copyPassword.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + info("Password was copied to clipboard"); + }); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_osAuth_not_shown_within_expiration_time() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyPassword = loginItem.shadowRoot.querySelector( + "copy-password-button" + ); + copyPassword.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + info( + "'Edit' shouldn't show the prompt since the user has authenticated now" + ); + let loginItem = content.document.querySelector("login-item"); Assert.ok( !loginItem.dataset.editing, "Not in edit mode before clicking 'Edit'" @@ -85,16 +248,43 @@ add_task(async function test() { ); Assert.ok(loginItem.dataset.editing, "In edit mode"); }); - - info("Test that the OS auth prompt is shown after about:logins is reopened"); BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_osAuth_shown_after_expiration_timeout() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } await BrowserTestUtils.openNewForegroundTab({ gBrowser, url: "about:logins", }); + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyPassword = loginItem.shadowRoot.querySelector( + "copy-password-button" + ); + copyPassword.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and authenticated"); + + // Show OS auth dialog since the timeout will have expired + + if (OSKeyStore.canReauth()) { + osAuthDialogShown = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } - // Show OS auth dialog since the page has been reloaded. - osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); let revealCheckbox = loginItem.shadowRoot.querySelector( @@ -103,63 +293,91 @@ add_task(async function test() { revealCheckbox.click(); }); await osAuthDialogShown; - info("OS auth dialog shown and canceled"); + info("OS auth dialog shown and authenticated"); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); - // Show OS auth dialog since the previous attempt was canceled - osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); +add_task(async function test_osAuth_shown_on_reload() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + let osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); - let revealCheckbox = loginItem.shadowRoot.querySelector( - ".reveal-password-checkbox" + let copyPassword = loginItem.shadowRoot.querySelector( + "copy-password-button" ); - revealCheckbox.click(); - info("clicking on reveal checkbox to hide the password"); - revealCheckbox.click(); + copyPassword.click(); }); await osAuthDialogShown; - info("OS auth dialog shown and passed"); + info("OS auth dialog shown and authenticated"); - // Show OS auth dialog since the timeout will have expired - osAuthDialogShown = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, + info("Test that the OS auth prompt is shown after about:logins is reopened"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", }); + + // Show OS auth dialog since the page has been reloaded. + osAuthDialogShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); let revealCheckbox = loginItem.shadowRoot.querySelector( ".reveal-password-checkbox" ); - info("clicking on reveal checkbox to reveal password"); revealCheckbox.click(); }); - info("waiting for os auth dialog"); await osAuthDialogShown; - info("OS auth dialog shown and passed after timeout expiration"); - - // Disable the OS auth feature and confirm the prompt doesn't appear - await SpecialPowers.pushPrefEnv({ - set: [["signon.management.page.os-auth.enabled", false]], - }); - info("Reload about:logins to reset the timeout"); + info("OS auth dialog shown and authenticated"); BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_osAuth_shown_again_on_cancel() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } await BrowserTestUtils.openNewForegroundTab({ gBrowser, url: "about:logins", }); - - info("'Edit' shouldn't show the prompt since the feature has been disabled"); + let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { let loginItem = content.document.querySelector("login-item"); - Assert.ok( - !loginItem.dataset.editing, - "Not in edit mode before clicking 'Edit'" + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" ); - let editButton = loginItem.shadowRoot.querySelector("edit-button"); - editButton.click(); - - await ContentTaskUtils.waitForCondition( - () => loginItem.dataset.editing, - "waiting for 'edit' mode" + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and canceled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + !revealCheckbox.checked, + "reveal checkbox should be unchecked if OS auth dialog canceled" ); - Assert.ok(loginItem.dataset.editing, "In edit mode"); }); + BrowserTestUtils.removeTab(gBrowser.selectedTab); }); diff --git a/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js index c5879ceeaf..bcd09ca9a2 100644 --- a/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js +++ b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js @@ -2,8 +2,6 @@ * http://creativecommons.org/publicdomain/zero/1.0/ */ /* eslint-disable mozilla/no-arbitrary-setTimeout */ -const OS_REAUTH_PREF = "signon.management.page.os-auth.enabled"; - async function openRemoveAllDialog(browser) { await SimpleTest.promiseFocus(browser); await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); @@ -80,9 +78,11 @@ async function waitForRemoveAllLogins() { } add_setup(async function () { - await SpecialPowers.pushPrefEnv({ - set: [[OS_REAUTH_PREF, false]], - }); + // Undo mocking from head.js + sinon.restore(); + + let oldPrefValue = LoginHelper.getOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF); + LoginHelper.setOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF, false); await BrowserTestUtils.openNewForegroundTab({ gBrowser, url: "about:logins", @@ -90,7 +90,7 @@ add_setup(async function () { registerCleanupFunction(async () => { BrowserTestUtils.removeTab(gBrowser.selectedTab); Services.logins.removeAllUserFacingLogins(); - await SpecialPowers.popPrefEnv(); + LoginHelper.setOSAuthEnabled(PASSWORDS_OS_REAUTH_PREF, oldPrefValue); }); TEST_LOGIN1 = await addLogin(TEST_LOGIN1); }); diff --git a/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js index 890d39a316..6e2047e8e5 100644 --- a/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js +++ b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js @@ -100,14 +100,6 @@ add_task(async function test_tab_key_nav() { expectedSelector ); - // By default, MacOS will skip over certain text controls, such as links. - if ( - content.window.navigator.platform.toLowerCase().includes("mac") && - expectedElement.tagName === "A" - ) { - continue; - } - const actualElement = getFocusedElement(); Assert.equal( @@ -126,13 +118,6 @@ add_task(async function test_tab_key_nav() { content.document, expectedSelector ); - // By default, MacOS will skip over certain text controls, such as links. - if ( - content.window.navigator.platform.toLowerCase().includes("mac") && - expectedElement.tagName === "A" - ) { - continue; - } const actualElement = getFocusedElement(); Assert.equal( diff --git a/browser/components/aboutlogins/tests/browser/browser_updateLogin.js b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js index 686b3951a1..192ff0270d 100644 --- a/browser/components/aboutlogins/tests/browser/browser_updateLogin.js +++ b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js @@ -124,7 +124,10 @@ add_task(async function test_login_item() { } let browser = gBrowser.selectedBrowser; - let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let reauthObserved = Promise.resolve(); + if (OSKeyStore.canReauth()) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await SpecialPowers.spawn( browser, [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], @@ -163,9 +166,11 @@ add_task(async function test_login_item() { ], test_discard_dialog ); - reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); let editButton = loginItem.shadowRoot @@ -184,9 +189,11 @@ add_task(async function test_login_item() { ], test_discard_dialog ); - reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); let editButton = loginItem.shadowRoot @@ -289,9 +296,11 @@ add_task(async function test_login_item() { ); } ); - reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); let editButton = loginItem.shadowRoot @@ -360,9 +369,11 @@ add_task(async function test_login_item() { "Password field width should be correctly updated" ); }); - reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ - loginResult: true, - }); + if (OSKeyStore.canReauth()) { + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + } await SpecialPowers.spawn(browser, [], async () => { let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); let editButton = loginItem.shadowRoot diff --git a/browser/components/aboutlogins/tests/browser/head.js b/browser/components/aboutlogins/tests/browser/head.js index 82d3cf2062..22ab7ef964 100644 --- a/browser/components/aboutlogins/tests/browser/head.js +++ b/browser/components/aboutlogins/tests/browser/head.js @@ -13,6 +13,24 @@ let { _AboutLogins } = ChromeUtils.importESModule( let { OSKeyStoreTestUtils } = ChromeUtils.importESModule( "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" ); + +const { OSKeyStore } = ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" +); + +let { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Always pretend OS Auth is enabled in this dir. +if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin() && OSKeyStore.canReauth()) { + // Enable OS reauth so we can test it. + sinon.stub(LoginHelper, "getOSAuthEnabled").returns(true); + registerCleanupFunction(() => { + sinon.restore(); + }); +} + var { LoginTestUtils } = ChromeUtils.importESModule( "resource://testing-common/LoginTestUtils.sys.mjs" ); @@ -53,6 +71,15 @@ let TEST_LOGIN3 = new nsLoginInfo( ); TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 123456; +const PASSWORDS_OS_REAUTH_PREF = "signon.management.page.os-auth.optout"; +const CryptoErrors = { + USER_CANCELED_PASSWORD: "User canceled primary password entry", + ENCRYPTION_FAILURE: "Couldn't encrypt string", + INVALID_ARG_ENCRYPT: "Need at least one plaintext to encrypt", + INVALID_ARG_DECRYPT: "Need at least one ciphertext to decrypt", + DECRYPTION_FAILURE: "Couldn't decrypt string", +}; + async function addLogin(login) { const result = await Services.logins.addLoginAsync(login); registerCleanupFunction(() => { @@ -153,6 +180,12 @@ add_setup(async function setup_head() { // Ignore MarionetteEvents error (Bug 1730837, Bug 1710079). return; } + if (msg.errorMessage.includes(CryptoErrors.DECRYPTION_FAILURE)) { + // Ignore decyption errors, we want to test if decryption failed + // But we cannot use try / catch in the test to catch this for some reason + // Bug 1403081 and Bug 1877720 + return; + } Assert.ok(false, msg.message || msg.errorMessage); }); diff --git a/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html index 68a58aee4f..afbae0c310 100644 --- a/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html +++ b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html @@ -24,6 +24,7 @@ Test the confirmation-dialog component </pre> <script> /** Test the confirmation-dialog component **/ +let isWin = navigator.platform.includes("Win"); let options = { title: "confirm-delete-dialog-title", @@ -65,7 +66,7 @@ add_task(async function test_initial_focus() { add_task(async function test_tab_focus() { gConfirmationDialog.show(options); ok(!gConfirmationDialog.hidden, "The dialog should be visible"); - sendKey("TAB"); + synthesizeKey("VK_TAB", { shiftKey: !isWin }); is(gConfirmationDialog.shadowRoot.activeElement, cancelButton, "After opening the dialog and tabbing once, the cancel button should be focused"); gConfirmationDialog.hide(); @@ -86,7 +87,7 @@ add_task(async function test_enter_key_to_cancel() { add_task(async function test_enter_key_to_confirm() { let showPromise = gConfirmationDialog.show(options); ok(!gConfirmationDialog.hidden, "The dialog should be visible"); - sendKey("TAB"); + synthesizeKey("VK_TAB", { shiftKey: !isWin }); sendKey("RETURN"); try { await showPromise; diff --git a/browser/components/aboutwelcome/content/aboutwelcome.css b/browser/components/aboutwelcome/content/aboutwelcome.css index 4e86b29e5d..0bde196f29 100644 --- a/browser/components/aboutwelcome/content/aboutwelcome.css +++ b/browser/components/aboutwelcome/content/aboutwelcome.css @@ -434,7 +434,7 @@ div#feature-callout.hidden { inset-inline: auto 0; margin-block: 16px 0; margin-inline: 0 16px; - background-color: var(--fc-background); + background-color: transparent; } #feature-callout .screen[pos=callout] .section-main .dismiss-button[button-size=small] { height: 24px; diff --git a/browser/components/aboutwelcome/content/aboutwelcome.html b/browser/components/aboutwelcome/content/aboutwelcome.html index eb56c63110..e7b9801092 100644 --- a/browser/components/aboutwelcome/content/aboutwelcome.html +++ b/browser/components/aboutwelcome/content/aboutwelcome.html @@ -27,7 +27,6 @@ <link rel="localization" href="browser/newtab/onboarding.ftl" /> <link rel="localization" href="browser/spotlight.ftl" /> <link rel="localization" href="browser/migrationWizard.ftl" /> - <link rel="localization" href="toolkit/branding/accounts.ftl" /> <link rel="localization" href="toolkit/branding/brandings.ftl" /> </head> <body> diff --git a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js index 1832b75778..8831947e2f 100644 --- a/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js +++ b/browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js @@ -362,7 +362,7 @@ add_task(async function test_aboutwelcome_embedded_migration() { await ContentTaskUtils.waitForEvent(selector, "focus"); } - selector.click(); + EventUtils.synthesizeMouseAtCenter(selector, {}, wizard.ownerGlobal); await shown; let panelRect = panelList.getBoundingClientRect(); diff --git a/browser/components/asrouter/content-src/styles/_feature-callout.scss b/browser/components/asrouter/content-src/styles/_feature-callout.scss index 40137fd29a..8a1b96db6f 100644 --- a/browser/components/asrouter/content-src/styles/_feature-callout.scss +++ b/browser/components/asrouter/content-src/styles/_feature-callout.scss @@ -359,7 +359,7 @@ inset-inline: auto 0; margin-block: 16px 0; margin-inline: 0 16px; - background-color: var(--fc-background); + background-color: transparent; &[button-size='small'] { height: 24px; diff --git a/browser/components/asrouter/content/asrouter-admin.bundle.js b/browser/components/asrouter/content/asrouter-admin.bundle.js index e5a1e6dd6a..aa5989ca29 100644 --- a/browser/components/asrouter/content/asrouter-admin.bundle.js +++ b/browser/components/asrouter/content/asrouter-admin.bundle.js @@ -335,6 +335,11 @@ for (const type of [ "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WALLPAPERS_SET", + "WALLPAPER_CLICK", + "WEATHER_IMPRESSION", + "WEATHER_LOAD_ERROR", + "WEATHER_OPEN_PROVIDER_URL", + "WEATHER_UPDATE", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { diff --git a/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css b/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css index de14572006..2e16438917 100644 --- a/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css +++ b/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css @@ -11,6 +11,16 @@ --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #000); --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #000); + --newtab-button-background: var(--button-background-color); + --newtab-button-focus-background: var(--newtab-button-background); + --newtab-button-focus-border: var(--focus-outline-color); + --newtab-button-hover-background: var(--button-background-color-hover); + --newtab-button-active-background: var(--button-background-color-active); + --newtab-button-text: var(--button-text-color); + --newtab-button-static-background: #F0F0F4; + --newtab-button-static-focus-background: var(--newtab-button-static-background); + --newtab-button-static-hover-background: #E0E0E6; + --newtab-button-static-active-background: #CFCFD8; --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); @@ -48,6 +58,9 @@ --newtab-primary-element-text-color: rgb(43, 42, 51); --newtab-wordmark-color: rgb(251, 251, 254); --newtab-status-success: #7C6; + --newtab-button-static-background: #2B2A33; + --newtab-button-static-hover-background: #52525E; + --newtab-button-static-active-background: #5B5B66; } @media (prefers-contrast) { @@ -61,7 +74,7 @@ background-size: 16px; -moz-context-properties: fill; display: inline-block; - color: var(--newtab-text-primary-color); + color: var(--icon-color); fill: currentColor; height: 16px; vertical-align: middle; @@ -111,6 +124,9 @@ .icon.icon-info { background-image: url("chrome://global/skin/icons/info.svg"); } +.icon.icon-info-critical { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-info-critical-16.svg"); +} .icon.icon-help { background-image: url("chrome://global/skin/icons/help.svg"); } @@ -191,6 +207,9 @@ .icon.icon-webextension { background-image: url("chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"); } +.icon.icon-weather { + background-image: url("chrome://browser/skin/weather/sunny.svg"); +} .icon.icon-highlights { background-image: url("chrome://global/skin/icons/highlights.svg"); } diff --git a/browser/components/asrouter/docs/targeting-attributes.md b/browser/components/asrouter/docs/targeting-attributes.md index b0049a4f1b..cabcd661f8 100644 --- a/browser/components/asrouter/docs/targeting-attributes.md +++ b/browser/components/asrouter/docs/targeting-attributes.md @@ -34,7 +34,6 @@ Please note that some targeting attributes require stricter controls on the tele * [hasMigratedPasswords](#hasmigratedpasswords) * [hasPinnedTabs](#haspinnedtabs) * [homePageSettings](#homepagesettings) -* [inMr2022Holdback](#inmr2022holdback) * [isBackgroundTaskMode](#isbackgroundtaskmode) * [isChinaRepack](#ischinarepack) * [isDefaultBrowser](#isdefaultbrowser) @@ -43,6 +42,7 @@ Please note that some targeting attributes require stricter controls on the tele * [isFxAEnabled](#isfxaenabled) * [isFxASignedIn](#isFxASignedIn) * [isMajorUpgrade](#ismajorupgrade) +* [isMSIX](#ismsix) * [isRTAMO](#isrtamo) * [launchOnLoginEnabled](#launchonloginenabled) * [locale](#locale) @@ -972,10 +972,6 @@ mode, or `null` if this invocation is not running in background task mode. Checks if user prefers reduced motion as indicated by the value of a media query for `prefers-reduced-motion`. -### `inMr2022Holdback` - -A boolean. `true` when the user is in the Major Release 2022 holdback study. - ### `distributionId` A string containing the id of the distribution, or the empty string if there @@ -1009,6 +1005,10 @@ A boolean. `true` if the user is configured to use the embedded Migration Wizard A boolean. `true` when [RTAMO](first-run.md#return-to-amo-rtamo) has been used to download Firefox, `false` otherwise. +### `isMSIX` + +A boolean. `true` when hasPackageId is `true` on Windows, `false` otherwise. + ### `isDeviceMigration` A boolean. `true` when [support.mozilla.org](https://support.mozilla.org) has been used to download the browser as part of a "migration" campaign, for device migration guidance, `false` otherwise. diff --git a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs index 9773eda270..2761481ceb 100644 --- a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs +++ b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs @@ -45,7 +45,6 @@ ChromeUtils.defineESModuleGetters(lazy, { ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", HomePage: "resource:///modules/HomePage.sys.mjs", - NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", Region: "resource://gre/modules/Region.sys.mjs", TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", @@ -847,6 +846,19 @@ const TargetingGetters = { return lazy.WindowsLaunchOnLogin.getLaunchOnLoginEnabled(); }, + get isMSIX() { + if (AppConstants.platform !== "win") { + return false; + } + // While we can write registry keys using external programs, we have no + // way of cleanup on uninstall. If we are on an MSIX build + // launch on login should never be enabled. + // Default to false so that the feature isn't unnecessarily + // disabled. + // See Bug 1888263. + return Services.sysinfo.getProperty("hasWinPackageId", false); + }, + /** * Is this invocation running in background task mode? * @@ -879,15 +891,6 @@ const TargetingGetters = { }, /** - * Whether or not the user is in the Major Release 2022 holdback study. - */ - get inMr2022Holdback() { - return ( - lazy.NimbusFeatures.majorRelease2022.getVariable("onboarding") === false - ); - }, - - /** * The distribution id, if any. * @return {string} */ diff --git a/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs index e0aa49ad49..c80ae323ab 100644 --- a/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs +++ b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs @@ -811,6 +811,74 @@ const CFR_MESSAGES = [ }, trigger: { id: "preferenceObserver", params: ["foo.bar"] }, }, + { + id: "FACEBOOK_CONTAINER_ADDON_A", + template: "cfr_doorhanger", + groups: ["cfr"], + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR", + anchor_id: "PanelUI-menu-button", + skip_address_bar_notifier: true, + icon_class: "cfr-doorhanger-medium-icon", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { + string_id: "cfr-doorhanger-extension-heading", + }, + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "954390", + title: "Facebook Container", + icon: "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/03c866df-82ea-489c-83c7-df6d0662d893.svg", + rating: "4.5", + users: "1.1M", + author: "Mozilla", + amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/", + }, + text: "Make it harder for Facebook to track your browsing activity, including info from medical and financial sites.", + buttons: { + primary: { + label: { + string_id: "firefoxview-cfr-primarybutton", + }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { + url: "https://example.com", + telemetrySource: "amo", + }, + }, + }, + secondary: [ + { + label: { + string_id: "firefoxview-cfr-secondarybutton", + }, + action: { + type: "CANCEL", + }, + }, + ], + }, + }, + frequency: { + lifetime: 1, + }, + targeting: + "!('@contain-facebook' in addonsInfo.addons|keys) && !('@testpilot-containers' in addonsInfo.addons|keys) && ('browser.discovery.enabled'|preferenceValue)", + trigger: { + id: "openURL", + params: ["www.facebook.com", "facebook.com"], + }, + }, ]; export const CFRMessageProvider = { diff --git a/browser/components/asrouter/modules/FeatureCallout.sys.mjs b/browser/components/asrouter/modules/FeatureCallout.sys.mjs index 5f0e266a4e..e8732b213d 100644 --- a/browser/components/asrouter/modules/FeatureCallout.sys.mjs +++ b/browser/components/asrouter/modules/FeatureCallout.sys.mjs @@ -462,6 +462,10 @@ export class FeatureCallout { * the callout should be aligned with which point on the anchor element. * @property {PopupAttachmentPoint} anchor_attachment * @property {PopupAttachmentPoint} callout_attachment + * @property {String} [panel_position_string] The attachments joined into a + * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup. + * This is not provided by JSON, but generated from anchor_attachment and + * callout_attachment. * @property {Number} [offset_x] Offset in pixels to apply to the callout * position in the horizontal direction. * @property {Number} [offset_y] The same in the vertical direction. @@ -514,8 +518,10 @@ export class FeatureCallout { */ /** - * @typedef {Object} AnchorConfig + * @typedef {Object} Anchor * @property {String} selector CSS selector for the anchor node. + * @property {Element} [element] The anchor node resolved from the selector. + * Not provided by JSON, but generated dynamically. * @property {PanelPosition} [panel_position] Used to show the callout in a * XUL panel. Only works in chrome documents, like the main browser window. * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in @@ -533,27 +539,13 @@ export class FeatureCallout { */ /** - * @typedef {Object} Anchor - * @property {String} selector - * @property {PanelPosition} [panel_position] - * @property {HTMLArrowPosition} [arrow_position] - * @property {PositionOverride} [absolute_position] - * @property {Boolean} [hide_arrow] - * @property {Boolean} [no_open_on_anchor] - * @property {Number} [arrow_width] - * @property {Element} element The anchor node resolved from the selector. - * @property {String} [panel_position_string] The panel_position joined into a - * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup. - */ - - /** * Return the first visible anchor element for the current screen. Screens can * specify multiple anchors in an array, and the first one that is visible * will be used. If none are visible, return null. * @returns {Anchor|null} */ _getAnchor() { - /** @type {AnchorConfig[]} */ + /** @type {Anchor[]} */ const anchors = Array.isArray(this.currentScreen?.anchors) ? this.currentScreen.anchors : []; @@ -565,9 +557,9 @@ export class FeatureCallout { continue; } const { selector, arrow_position, panel_position } = anchor; - let panel_position_string; if (panel_position) { - panel_position_string = this._getPanelPositionString(panel_position); + let panel_position_string = + this._getPanelPositionString(panel_position); // if the positionString doesn't match the format we expect, don't // render the callout. if (!panel_position_string && !arrow_position) { @@ -580,6 +572,7 @@ export class FeatureCallout { ); continue; } + panel_position.panel_position_string = panel_position_string; } if ( arrow_position && @@ -637,7 +630,7 @@ export class FeatureCallout { continue; } } - return { ...anchor, panel_position_string, element }; + return { ...anchor, element }; } return null; } @@ -752,13 +745,10 @@ export class FeatureCallout { } const { autohide, padding } = this.currentScreen.content; - const { - panel_position_string, - hide_arrow, - no_open_on_anchor, - arrow_width, - } = anchor; - const needsPanel = "MozXULElement" in this.win && !!panel_position_string; + const { panel_position, hide_arrow, no_open_on_anchor, arrow_width } = + anchor; + const needsPanel = + "MozXULElement" in this.win && !!panel_position?.panel_position_string; if (this._container) { if (needsPanel ^ (this._container?.localName === "panel")) { @@ -775,7 +765,7 @@ export class FeatureCallout { noautofocus="true" flip="slide" type="arrow" - position="${panel_position_string}" + position="${panel_position.panel_position_string}" ${hide_arrow ? "" : 'show-arrow=""'} ${autohide ? "" : 'noautohide="true"'} ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""} @@ -1742,17 +1732,21 @@ export class FeatureCallout { }); } else if (this._container.localName === "panel") { const anchor = this._getAnchor(); - if (!anchor) { + if (!anchor?.panel_position) { this.endTour(); return; } - const position = anchor.panel_position_string; + const { + panel_position_string: position, + offset_x: x, + offset_y: y, + } = anchor.panel_position; this._container.addEventListener("popupshown", onRender, { once: true, }); this._container.addEventListener("popuphiding", this); this._addPanelConflictListeners(); - this._container.openPopup(anchor.element, { position }); + this._container.openPopup(anchor.element, { position, x, y }); } } }); diff --git a/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs index 38c9a8d848..4fda4355bf 100644 --- a/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs +++ b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs @@ -157,7 +157,7 @@ const MESSAGES = () => { // Add the highest possible cap to ensure impressions are recorded while allowing the Spotlight to sync across windows/tabs with Firefox View open lifetime: 100, }, - targeting: `!inMr2022Holdback && source == "about:firefoxview" && + targeting: `source == "about:firefoxview" && !'browser.newtabpage.activity-stream.asrouter.providers.cfr'|preferenceIsUserSet && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && ${matchCurrentScreenTargeting( @@ -303,7 +303,7 @@ const MESSAGES = () => { ], }, priority: 3, - targeting: `!inMr2022Holdback && source == "about:firefoxview" && ${matchCurrentScreenTargeting( + targeting: `source == "about:firefoxview" && ${matchCurrentScreenTargeting( FIREFOX_VIEW_PREF, "FEATURE_CALLOUT_[0-9]" )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`, @@ -376,7 +376,7 @@ const MESSAGES = () => { ], }, priority: 2, - targeting: `!inMr2022Holdback && source == "about:firefoxview" && "browser.firefox-view.view-count" | preferenceValue > 2 + targeting: `source == "about:firefoxview" && "browser.firefox-view.view-count" | preferenceValue > 2 && (("identity.fxaccounts.enabled" | preferenceValue == false) || !(("services.sync.engine.tabs" | preferenceValue == true) && ("services.sync.username" | preferenceValue))) && (!messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] || messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] < currentDate|date - ${ONE_DAY_IN_MS})`, frequency: { lifetime: 1, diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs index 3cfbbb3f34..298599e42b 100644 --- a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs +++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs @@ -24,7 +24,6 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", - NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", ShellService: "resource:///modules/ShellService.sys.mjs", }); @@ -52,7 +51,6 @@ const L10N = new Localization([ "branding/brand.ftl", "browser/newtab/onboarding.ftl", "toolkit/branding/brandings.ftl", - "toolkit/branding/accounts.ftl", ]); const HOMEPAGE_PREF = "browser.startup.homepage"; @@ -872,7 +870,7 @@ const BASE_MESSAGES = () => [ ], lifetime: 12, }, - targeting: "!inMr2022Holdback && doesAppNeedPrivatePin", + targeting: "doesAppNeedPrivatePin", }, { id: "PB_NEWTAB_COOKIE_BANNERS_PROMO", @@ -988,7 +986,7 @@ const BASE_MESSAGES = () => [ targeting: `source == 'newtab' && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications - && !launchOnLoginEnabled`, + && !launchOnLoginEnabled && !isMSIX`, }, { id: "INFOBAR_LAUNCH_ON_LOGIN_FINAL", @@ -1056,7 +1054,7 @@ const BASE_MESSAGES = () => [ && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] < currentDate|date - ${FOURTEEN_DAYS_IN_MS} - && !launchOnLoginEnabled`, + && !launchOnLoginEnabled && !isMSIX`, }, { id: "FOX_DOODLE_SET_DEFAULT", @@ -1375,9 +1373,8 @@ export const OnboardingMessageProvider = { return checkDefault && !isDefault; }, _shouldShowPrivacySegmentationScreen() { - // Fall back to pref: browser.privacySegmentation.preferences.show - return lazy.NimbusFeatures.majorRelease2022.getVariable( - "feltPrivacyShowPreferencesSection" + return Services.prefs.getBoolPref( + "browser.privacySegmentation.preferences.show" ); }, _doesHomepageNeedReset() { diff --git a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs index 5180e2e6a2..8dbb718bfd 100644 --- a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs +++ b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs @@ -37,7 +37,7 @@ const MESSAGES = () => [ }, sumo_path: "https://example.com", }, - text: { string_id: "cfr-doorhanger-bookmark-fxa-body" }, + text: { string_id: "cfr-doorhanger-bookmark-fxa-body-2" }, icon: "chrome://branding/content/icon64.png", icon_class: "cfr-doorhanger-large-icon", persistent_doorhanger: true, diff --git a/browser/components/asrouter/modules/RemoteL10n.sys.mjs b/browser/components/asrouter/modules/RemoteL10n.sys.mjs index 1df10fbd72..4135d77191 100644 --- a/browser/components/asrouter/modules/RemoteL10n.sys.mjs +++ b/browser/components/asrouter/modules/RemoteL10n.sys.mjs @@ -204,7 +204,6 @@ export class _RemoteL10n { "branding/brand.ftl", "browser/defaultBrowserNotification.ftl", "browser/newtab/asrouter.ftl", - "toolkit/branding/accounts.ftl", "toolkit/branding/brandings.ftl", ], false diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js index b80b3ec7a4..a01b2cf14c 100644 --- a/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js +++ b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js @@ -107,7 +107,7 @@ add_task(async function react_to_trigger() { "Notification has default priority" ); // Dismiss the notification - notificationStack.currentNotification.closeButtonEl.click(); + notificationStack.currentNotification.closeButton.click(); }); add_task(async function dismiss_telemetry() { @@ -128,7 +128,7 @@ add_task(async function dismiss_telemetry() { // Remove any IMPRESSION pings dispatchStub.reset(); - infobar.notification.closeButtonEl.click(); + infobar.notification.closeButton.click(); await BrowserTestUtils.waitForCondition( () => infobar.notification === null, @@ -204,7 +204,7 @@ add_task(async function prevent_multiple_messages() { Assert.equal(dispatchStub.callCount, 2, "Impression count did not increase"); // Dismiss the first notification - infobar.notification.closeButtonEl.click(); + infobar.notification.closeButton.click(); Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); // Reset impressions count @@ -218,6 +218,6 @@ add_task(async function prevent_multiple_messages() { Assert.ok(InfoBar._activeInfobar, "activeInfobar is set"); Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION"); // Dismiss the notification again - infobar.notification.closeButtonEl.click(); + infobar.notification.closeButton.click(); Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); }); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js index 55f278fd7d..e20a75c5ab 100644 --- a/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js +++ b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js @@ -1226,47 +1226,6 @@ add_task(async function check_userPrefersReducedMotion() { ); }); -add_task(async function test_mr2022Holdback() { - await ExperimentAPI.ready(); - - ok( - !ASRouterTargeting.Environment.inMr2022Holdback, - "Should not be in holdback (no experiment)" - ); - - { - const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ - featureId: "majorRelease2022", - value: { - onboarding: true, - }, - }); - - ok( - !ASRouterTargeting.Environment.inMr2022Holdback, - "Should not be in holdback (onboarding = true)" - ); - - await doExperimentCleanup(); - } - - { - const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ - featureId: "majorRelease2022", - value: { - onboarding: false, - }, - }); - - ok( - ASRouterTargeting.Environment.inMr2022Holdback, - "Should be in holdback (onboarding = false)" - ); - - await doExperimentCleanup(); - } -}); - add_task(async function test_distributionId() { is( ASRouterTargeting.Environment.distributionId, @@ -1469,6 +1428,28 @@ add_task(async function check_useEmbeddedMigrationWizard() { ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard)); }); +add_task(async function check_isMSIX() { + is( + typeof ASRouterTargeting.Environment.isMSIX, + "boolean", + "Should return a boolean" + ); + if (AppConstants.platform !== "win") { + is( + ASRouterTargeting.Environment.isMSIX, + false, + "Should always be false on non-Windows" + ); + return; + } + + is( + ASRouterTargeting.Environment.isMSIX, + Services.sysinfo.getProperty("hasWinPackageId"), + "Should match the value from sysinfo" + ); +}); + add_task(async function check_isRTAMO() { is( typeof ASRouterTargeting.Environment.isRTAMO, diff --git a/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js index fe6959852c..ec35806d11 100644 --- a/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js +++ b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js @@ -1,32 +1,9 @@ import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; -const REGULAR_IDS = [ - "FACEBOOK_CONTAINER", - "GOOGLE_TRANSLATE", - "YOUTUBE_ENHANCE", - // These are excluded for now. - // "WIKIPEDIA_CONTEXT_MENU_SEARCH", - // "REDDIT_ENHANCEMENT", -]; - describe("CFRMessageProvider", () => { let messages; beforeEach(async () => { messages = await CFRMessageProvider.getMessages(); }); - it("should have a total of 11 messages", () => { - assert.lengthOf(messages, 11); - }); - it("should have one message each for the three regular addons", () => { - for (const id of REGULAR_IDS) { - const cohort3 = messages.find(msg => msg.id === `${id}_3`); - assert.ok(cohort3, `contains three day cohort for ${id}`); - assert.deepEqual( - cohort3.frequency, - { lifetime: 3 }, - "three day cohort has the right frequency cap" - ); - assert.notInclude(cohort3.targeting, `providerCohorts.cfr`); - } - }); + it("should have messages", () => assert.ok(messages.length)); }); diff --git a/browser/components/asrouter/tests/unit/RemoteL10n.test.js b/browser/components/asrouter/tests/unit/RemoteL10n.test.js index dd0f858750..0e32442522 100644 --- a/browser/components/asrouter/tests/unit/RemoteL10n.test.js +++ b/browser/components/asrouter/tests/unit/RemoteL10n.test.js @@ -81,7 +81,6 @@ describe("RemoteL10n", () => { "branding/brand.ftl", "browser/defaultBrowserNotification.ftl", "browser/newtab/asrouter.ftl", - "toolkit/branding/accounts.ftl", "toolkit/branding/brandings.ftl", ]); assert.isFalse(args[1]); @@ -103,7 +102,6 @@ describe("RemoteL10n", () => { "branding/brand.ftl", "browser/defaultBrowserNotification.ftl", "browser/newtab/asrouter.ftl", - "toolkit/branding/accounts.ftl", "toolkit/branding/brandings.ftl", ]); assert.isFalse(args[1]); diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs index 3521f315fd..05634ed2c8 100644 --- a/browser/components/backup/BackupService.sys.mjs +++ b/browser/components/backup/BackupService.sys.mjs @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; @@ -15,12 +16,25 @@ ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { }); }); +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ClientID: "resource://gre/modules/ClientID.sys.mjs", + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", +}); + /** * The BackupService class orchestrates the scheduling and creation of profile * backups. It also does most of the heavy lifting for the restoration of a * profile backup. */ -export class BackupService { +export class BackupService extends EventTarget { /** * The BackupService singleton instance. * @@ -37,11 +51,138 @@ export class BackupService { #resources = new Map(); /** + * Set to true if a backup is currently in progress. Causes stateUpdate() + * to be called. + * + * @see BackupService.stateUpdate() + * @param {boolean} val + * True if a backup is in progress. + */ + set #backupInProgress(val) { + if (this.#_state.backupInProgress != val) { + this.#_state.backupInProgress = val; + this.stateUpdate(); + } + } + + /** * True if a backup is currently in progress. * * @type {boolean} */ - #backupInProgress = false; + get #backupInProgress() { + return this.#_state.backupInProgress; + } + + /** + * Dispatches an event to let listeners know that the BackupService state + * object has been updated. + */ + stateUpdate() { + this.dispatchEvent(new CustomEvent("BackupService:StateUpdate")); + } + + /** + * An object holding the current state of the BackupService instance, for + * the purposes of representing it in the user interface. Ideally, this would + * be named #state instead of #_state, but sphinx-js seems to be fairly + * unhappy with that coupled with the ``state`` getter. + * + * @type {object} + */ + #_state = { backupInProgress: false }; + + /** + * A Promise that will resolve once the postRecovery steps are done. It will + * also resolve if postRecovery steps didn't need to run. + * + * @see BackupService.checkForPostRecovery() + * @type {Promise<undefined>} + */ + #postRecoveryPromise; + + /** + * The resolving function for #postRecoveryPromise, which should be called + * by checkForPostRecovery() before exiting. + * + * @type {Function} + */ + #postRecoveryResolver; + + /** + * The name of the backup manifest file. + * + * @type {string} + */ + static get MANIFEST_FILE_NAME() { + return "backup-manifest.json"; + } + + /** + * The current schema version of the backup manifest that this BackupService + * uses when creating a backup. + * + * @type {number} + */ + static get MANIFEST_SCHEMA_VERSION() { + return 1; + } + + /** + * A promise that resolves to the schema for the backup manifest that this + * BackupService uses when creating a backup. This should be accessed via + * the `MANIFEST_SCHEMA` static getter. + * + * @type {Promise<object>} + */ + static #manifestSchemaPromise = null; + + /** + * The current schema version of the backup manifest that this BackupService + * uses when creating a backup. + * + * @type {Promise<object>} + */ + static get MANIFEST_SCHEMA() { + if (!BackupService.#manifestSchemaPromise) { + BackupService.#manifestSchemaPromise = BackupService._getSchemaForVersion( + BackupService.MANIFEST_SCHEMA_VERSION + ); + } + + return BackupService.#manifestSchemaPromise; + } + + /** + * The name of the post recovery file written into the newly created profile + * directory just after a profile is recovered from a backup. + * + * @type {string} + */ + static get POST_RECOVERY_FILE_NAME() { + return "post-recovery.json"; + } + + /** + * Returns the schema for the backup manifest for a given version. + * + * This should really be #getSchemaForVersion, but for some reason, + * sphinx-js seems to choke on static async private methods (bug 1893362). + * We workaround this breakage by using the `_` prefix to indicate that this + * method should be _considered_ private, and ask that you not use this method + * outside of this class. The sphinx-js issue is tracked at + * https://github.com/mozilla/sphinx-js/issues/240. + * + * @private + * @param {number} version + * The version of the schema to return. + * @returns {Promise<object>} + */ + static async _getSchemaForVersion(version) { + let schemaURL = `chrome://browser/content/backup/BackupManifest.${version}.schema.json`; + let response = await fetch(schemaURL); + return response.json(); + } /** * Returns a reference to a BackupService singleton. If this is the first time @@ -56,7 +197,10 @@ export class BackupService { return this.#instance; } this.#instance = new BackupService(DefaultBackupResources); - this.#instance.takeMeasurements(); + + this.#instance.checkForPostRecovery().then(() => { + this.#instance.takeMeasurements(); + }); return this.#instance; } @@ -81,15 +225,49 @@ export class BackupService { * @param {object} [backupResources=DefaultBackupResources] - Object containing BackupResource classes to associate with this service. */ constructor(backupResources = DefaultBackupResources) { + super(); lazy.logConsole.debug("Instantiated"); for (const resourceName in backupResources) { let resource = backupResources[resourceName]; this.#resources.set(resource.key, resource); } + + let { promise, resolve } = Promise.withResolvers(); + this.#postRecoveryPromise = promise; + this.#postRecoveryResolver = resolve; + } + + /** + * Returns a reference to a Promise that will resolve with undefined once + * postRecovery steps have had a chance to run. This will also be resolved + * with undefined if no postRecovery steps needed to be run. + * + * @see BackupService.checkForPostRecovery() + * @returns {Promise<undefined>} + */ + get postRecoveryComplete() { + return this.#postRecoveryPromise; } /** + * Returns a state object describing the state of the BackupService for the + * purposes of representing it in the user interface. The returned state + * object is immutable. + * + * @type {object} + */ + get state() { + return Object.freeze(structuredClone(this.#_state)); + } + + /** + * @typedef {object} CreateBackupResult + * @property {string} stagingPath + * The staging path for where the backup was created. + */ + + /** * Create a backup of the user's profile. * * @param {object} [options] @@ -97,19 +275,22 @@ export class BackupService { * @param {string} [options.profilePath=PathUtils.profileDir] * The path to the profile to backup. By default, this is the current * profile. - * @returns {Promise<undefined>} + * @returns {Promise<CreateBackupResult|null>} + * A promise that resolves to an object containing the path to the staging + * folder where the backup was created, or null if the backup failed. */ 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; + return null; } this.#backupInProgress = true; try { lazy.logConsole.debug(`Creating backup for profile at ${profilePath}`); + let manifest = await this.#createBackupManifest(); // First, check to see if a `backups` directory already exists in the // profile. @@ -122,8 +303,15 @@ export class BackupService { let stagingPath = await this.#prepareStagingFolder(backupDirPath); + // Sort resources be priority. + let sortedResources = Array.from(this.#resources.values()).sort( + (a, b) => { + return b.priority - a.priority; + } + ); + // Perform the backup for each resource. - for (let resourceClass of this.#resources.values()) { + for (let resourceClass of sortedResources) { try { lazy.logConsole.debug( `Backing up resource with key ${resourceClass.key}. ` + @@ -139,10 +327,19 @@ export class BackupService { resourcePath, profilePath ); - lazy.logConsole.debug( - `Backup of resource with key ${resourceClass.key} completed`, - manifestEntry - ); + + if (manifestEntry === undefined) { + lazy.logConsole.error( + `Backup of resource with key ${resourceClass.key} returned undefined + as its ManifestEntry instead of null or an object` + ); + } else { + lazy.logConsole.debug( + `Backup of resource with key ${resourceClass.key} completed`, + manifestEntry + ); + manifest.resources[resourceClass.key] = manifestEntry; + } } catch (e) { lazy.logConsole.error( `Failed to backup resource: ${resourceClass.key}`, @@ -150,6 +347,42 @@ export class BackupService { ); } } + + // Ensure that the manifest abides by the current schema, and log + // an error if somehow it doesn't. We'll want to collect telemetry for + // this case to make sure it's not happening in the wild. We debated + // throwing an exception here too, but that's not meaningfully better + // than creating a backup that's not schema-compliant. At least in this + // case, a user so-inclined could theoretically repair the manifest + // to make it valid. + let manifestSchema = await BackupService.MANIFEST_SCHEMA; + let schemaValidationResult = lazy.JsonSchemaValidator.validate( + manifest, + manifestSchema + ); + if (!schemaValidationResult.valid) { + lazy.logConsole.error( + "Backup manifest does not conform to schema:", + manifest, + manifestSchema, + schemaValidationResult + ); + // TODO: Collect telemetry for this case. (bug 1891817) + } + + // Write the manifest to the staging folder. + let manifestPath = PathUtils.join( + stagingPath, + BackupService.MANIFEST_FILE_NAME + ); + await IOUtils.writeJSON(manifestPath, manifest); + + let renamedStagingPath = await this.#finalizeStagingFolder(stagingPath); + lazy.logConsole.log( + "Wrote backup to staging directory at ", + renamedStagingPath + ); + return { stagingPath: renamedStagingPath }; } finally { this.#backupInProgress = false; } @@ -179,6 +412,336 @@ export class BackupService { } /** + * Renames the staging folder to an ISO 8601 date string with dashes replacing colons and fractional seconds stripped off. + * The ISO date string should be formatted from YYYY-MM-DDTHH:mm:ss.sssZ to YYYY-MM-DDTHH-mm-ssZ + * + * @param {string} stagingPath + * The path to the populated staging folder. + * @returns {Promise<string|null>} + * The path to the renamed staging folder, or null if the stagingPath was + * not pointing to a valid folder. + */ + async #finalizeStagingFolder(stagingPath) { + if (!(await IOUtils.exists(stagingPath))) { + // If we somehow can't find the specified staging folder, cancel this step. + lazy.logConsole.error( + `Failed to finalize staging folder. Cannot find ${stagingPath}.` + ); + return null; + } + + try { + lazy.logConsole.debug("Finalizing and renaming staging folder"); + let currentDateISO = new Date().toISOString(); + // First strip the fractional seconds + let dateISOStripped = currentDateISO.replace(/\.\d+\Z$/, "Z"); + // Now replace all colons with dashes + let dateISOFormatted = dateISOStripped.replaceAll(":", "-"); + + let stagingPathParent = PathUtils.parent(stagingPath); + let renamedBackupPath = PathUtils.join( + stagingPathParent, + dateISOFormatted + ); + await IOUtils.move(stagingPath, renamedBackupPath); + + let existingBackups = await IOUtils.getChildren(stagingPathParent); + + /** + * Bug 1892532: for now, we only support a single backup file. + * If there are other pre-existing backup folders, delete them. + */ + for (let existingBackupPath of existingBackups) { + if (existingBackupPath !== renamedBackupPath) { + await IOUtils.remove(existingBackupPath, { + recursive: true, + }); + } + } + return renamedBackupPath; + } catch (e) { + lazy.logConsole.error( + `Something went wrong while finalizing the staging folder. ${e}` + ); + throw e; + } + } + + /** + * Creates and resolves with a backup manifest object with an empty resources + * property. + * + * @returns {Promise<object>} + */ + async #createBackupManifest() { + let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + let profileName; + if (!profileSvc.currentProfile) { + // We're probably running on a local build or in some special configuration. + // Let's pull in a profile name from the profile directory. + let profileFolder = PathUtils.split(PathUtils.profileDir).at(-1); + profileName = profileFolder.substring(profileFolder.indexOf(".") + 1); + } else { + profileName = profileSvc.currentProfile.name; + } + + let meta = { + date: new Date().toISOString(), + appName: AppConstants.MOZ_APP_NAME, + appVersion: AppConstants.MOZ_APP_VERSION, + buildID: AppConstants.MOZ_BUILDID, + profileName, + machineName: lazy.fxAccounts.device.getLocalName(), + osName: Services.sysinfo.getProperty("name"), + osVersion: Services.sysinfo.getProperty("version"), + legacyClientID: await lazy.ClientID.getClientID(), + }; + + let fxaState = lazy.UIState.get(); + if (fxaState.status == lazy.UIState.STATUS_SIGNED_IN) { + meta.accountID = fxaState.uid; + meta.accountEmail = fxaState.email; + } + + return { + version: BackupService.MANIFEST_SCHEMA_VERSION, + meta, + resources: {}, + }; + } + + /** + * Given a decompressed backup archive at recoveryPath, this method does the + * following: + * + * 1. Reads in the backup manifest from the archive and ensures that it is + * valid. + * 2. Creates a new named profile directory using the same name as the one + * found in the backup manifest, but with a different prefix. + * 3. Iterates over each resource in the manifest and calls the recover() + * method on each found BackupResource, passing in the associated + * ManifestEntry from the backup manifest, and collects any post-recovery + * data from those resources. + * 4. Writes a `post-recovery.json` file into the newly created profile + * directory. + * 5. Returns the name of the newly created profile directory. + * + * @param {string} recoveryPath + * The path to the decompressed backup archive on the file system. + * @param {boolean} [shouldLaunch=false] + * An optional argument that specifies whether an instance of the app + * should be launched with the newly recovered profile after recovery is + * complete. + * @param {string} [profileRootPath=null] + * An optional argument that specifies the root directory where the new + * profile directory should be created. If not provided, the default + * profile root directory will be used. This is primarily meant for + * testing. + * @returns {Promise<nsIToolkitProfile>} + * The nsIToolkitProfile that was created for the recovered profile. + * @throws {Exception} + * In the event that recovery somehow failed. + */ + async recoverFromBackup( + recoveryPath, + shouldLaunch = false, + profileRootPath = null + ) { + lazy.logConsole.debug("Recovering from backup at ", recoveryPath); + + try { + // Read in the backup manifest. + let manifestPath = PathUtils.join( + recoveryPath, + BackupService.MANIFEST_FILE_NAME + ); + let manifest = await IOUtils.readJSON(manifestPath); + if (!manifest.version) { + throw new Error("Backup manifest version not found"); + } + + if (manifest.version > BackupService.MANIFEST_SCHEMA_VERSION) { + throw new Error( + "Cannot recover from a manifest newer than the current schema version" + ); + } + + // Make sure that it conforms to the schema. + let manifestSchema = await BackupService._getSchemaForVersion( + manifest.version + ); + let schemaValidationResult = lazy.JsonSchemaValidator.validate( + manifest, + manifestSchema + ); + if (!schemaValidationResult.valid) { + lazy.logConsole.error( + "Backup manifest does not conform to schema:", + manifest, + manifestSchema, + schemaValidationResult + ); + // TODO: Collect telemetry for this case. (bug 1891817) + throw new Error("Cannot recover from an invalid backup manifest"); + } + + // In the future, if we ever bump the MANIFEST_SCHEMA_VERSION and need to + // do any special behaviours to interpret older schemas, this is where we + // can do that, and we can remove this comment. + + let meta = manifest.meta; + + // Okay, we have a valid backup-manifest.json. Let's create a new profile + // and start invoking the recover() method on each BackupResource. + let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + let profile = profileSvc.createUniqueProfile( + profileRootPath ? await IOUtils.getDirectory(profileRootPath) : null, + meta.profileName + ); + + let postRecovery = {}; + + // Iterate over each resource in the manifest and call recover() on each + // associated BackupResource. + for (let resourceKey in manifest.resources) { + let manifestEntry = manifest.resources[resourceKey]; + let resourceClass = this.#resources.get(resourceKey); + if (!resourceClass) { + lazy.logConsole.error( + `No BackupResource found for key ${resourceKey}` + ); + continue; + } + + try { + lazy.logConsole.debug( + `Restoring resource with key ${resourceKey}. ` + + `Requires encryption: ${resourceClass.requiresEncryption}` + ); + let resourcePath = PathUtils.join(recoveryPath, resourceKey); + let postRecoveryEntry = await new resourceClass().recover( + manifestEntry, + resourcePath, + profile.rootDir.path + ); + postRecovery[resourceKey] = postRecoveryEntry; + } catch (e) { + lazy.logConsole.error( + `Failed to recover resource: ${resourceKey}`, + e + ); + } + } + + // Make sure that a legacy telemetry client ID exists and is written to + // disk. + let clientID = await lazy.ClientID.getClientID(); + lazy.logConsole.debug("Current client ID: ", clientID); + // Next, copy over the legacy telemetry client ID state from the currently + // running profile. The newly created profile that we're recovering into + // should inherit this client ID. + const TELEMETRY_STATE_FILENAME = "state.json"; + const TELEMETRY_STATE_FOLDER = "datareporting"; + await IOUtils.makeDirectory( + PathUtils.join(profile.rootDir.path, TELEMETRY_STATE_FOLDER) + ); + await IOUtils.copy( + /* source */ + PathUtils.join( + PathUtils.profileDir, + TELEMETRY_STATE_FOLDER, + TELEMETRY_STATE_FILENAME + ), + /* destination */ + PathUtils.join( + profile.rootDir.path, + TELEMETRY_STATE_FOLDER, + TELEMETRY_STATE_FILENAME + ) + ); + + let postRecoveryPath = PathUtils.join( + profile.rootDir.path, + BackupService.POST_RECOVERY_FILE_NAME + ); + await IOUtils.writeJSON(postRecoveryPath, postRecovery); + + profileSvc.flush(); + + if (shouldLaunch) { + Services.startup.createInstanceWithProfile(profile); + } + + return profile; + } catch (e) { + lazy.logConsole.error( + "Failed to recover from backup at ", + recoveryPath, + e + ); + throw e; + } + } + + /** + * Checks for the POST_RECOVERY_FILE_NAME in the current profile directory. + * If one exists, instantiates any relevant BackupResource's, and calls + * postRecovery() on them with the appropriate entry from the file. Once + * this is done, deletes the file. + * + * The file is deleted even if one of the postRecovery() steps rejects or + * fails. + * + * This function resolves silently if the POST_RECOVERY_FILE_NAME file does + * not exist, which should be the majority of cases. + * + * @param {string} [profilePath=PathUtils.profileDir] + * The profile path to look for the POST_RECOVERY_FILE_NAME file. Defaults + * to the current profile. + * @returns {Promise<undefined>} + */ + async checkForPostRecovery(profilePath = PathUtils.profileDir) { + lazy.logConsole.debug(`Checking for post-recovery file in ${profilePath}`); + let postRecoveryFile = PathUtils.join( + profilePath, + BackupService.POST_RECOVERY_FILE_NAME + ); + + if (!(await IOUtils.exists(postRecoveryFile))) { + lazy.logConsole.debug("Did not find post-recovery file."); + this.#postRecoveryResolver(); + return; + } + + lazy.logConsole.debug("Found post-recovery file. Loading..."); + + try { + let postRecovery = await IOUtils.readJSON(postRecoveryFile); + for (let resourceKey in postRecovery) { + let postRecoveryEntry = postRecovery[resourceKey]; + let resourceClass = this.#resources.get(resourceKey); + if (!resourceClass) { + lazy.logConsole.error( + `Invalid resource for post-recovery step: ${resourceKey}` + ); + continue; + } + + lazy.logConsole.debug(`Running post-recovery step for ${resourceKey}`); + await new resourceClass().postRecovery(postRecoveryEntry); + lazy.logConsole.debug(`Done post-recovery step for ${resourceKey}`); + } + } finally { + await IOUtils.remove(postRecoveryFile, { ignoreAbsent: true }); + this.#postRecoveryResolver(); + } + } + + /** * Take measurements of the current profile state for Telemetry. * * @returns {Promise<undefined>} diff --git a/browser/components/backup/actors/BackupUIChild.sys.mjs b/browser/components/backup/actors/BackupUIChild.sys.mjs new file mode 100644 index 0000000000..25d013fa8e --- /dev/null +++ b/browser/components/backup/actors/BackupUIChild.sys.mjs @@ -0,0 +1,54 @@ +/* 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/. */ + +/** + * A JSWindowActor that is responsible for marshalling information between + * the BackupService singleton and any registered UI widgets that need to + * represent data from that service. Any UI widgets that want to receive + * state updates from BackupService should emit a BackupUI:InitWidget + * event in a document that this actor pair is registered for. + */ +export class BackupUIChild extends JSWindowActorChild { + #inittedWidgets = new WeakSet(); + + /** + * Handles BackupUI:InitWidget custom events fired by widgets that want to + * register with BackupUIChild. Firing this event sends a message to the + * parent to request the BackupService state which will result in a + * `backupServiceState` property of the widget to be set when that state is + * received. Subsequent state updates will also cause that state property to + * be set. + * + * @param {Event} event + * The BackupUI:InitWidget custom event that the widget fired. + */ + handleEvent(event) { + if (event.type == "BackupUI:InitWidget") { + this.#inittedWidgets.add(event.target); + this.sendAsyncMessage("RequestState"); + } + } + + /** + * Handles messages sent by BackupUIParent. + * + * @param {ReceiveMessageArgument} message + * The message received from the BackupUIParent. + */ + receiveMessage(message) { + if (message.name == "StateUpdate") { + let widgets = ChromeUtils.nondeterministicGetWeakSetKeys( + this.#inittedWidgets + ); + for (let widget of widgets) { + if (widget.isConnected) { + // Note: we might need to switch to using Cu.cloneInto here in the + // event that these widgets are embedded in a non-parent-process + // context, like in an onboarding card. + widget.backupServiceState = message.data.state; + } + } + } + } +} diff --git a/browser/components/backup/actors/BackupUIParent.sys.mjs b/browser/components/backup/actors/BackupUIParent.sys.mjs new file mode 100644 index 0000000000..e4d0f3aace --- /dev/null +++ b/browser/components/backup/actors/BackupUIParent.sys.mjs @@ -0,0 +1,82 @@ +/* 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, { + BackupService: "resource:///modules/backup/BackupService.sys.mjs", +}); + +/** + * A JSWindowActor that is responsible for marshalling information between + * the BackupService singleton and any registered UI widgets that need to + * represent data from that service. + */ +export class BackupUIParent extends JSWindowActorParent { + /** + * A reference to the BackupService singleton instance. + * + * @type {BackupService} + */ + #bs; + + /** + * Create a BackupUIParent instance. If a BackupUIParent is instantiated + * before BrowserGlue has a chance to initialize the BackupService, this + * constructor will cause it to initialize first. + */ + constructor() { + super(); + // We use init() rather than get(), since it's possible to load + // about:preferences before the service has had a chance to init itself + // via BrowserGlue. + this.#bs = lazy.BackupService.init(); + } + + /** + * Called once the BackupUIParent/BackupUIChild pair have been connected. + */ + actorCreated() { + this.#bs.addEventListener("BackupService:StateUpdate", this); + } + + /** + * Called once the BackupUIParent/BackupUIChild pair have been disconnected. + */ + didDestroy() { + this.#bs.removeEventListener("BackupService:StateUpdate", this); + } + + /** + * Handles events fired by the BackupService. + * + * @param {Event} event + * The event that the BackupService emitted. + */ + handleEvent(event) { + if (event.type == "BackupService:StateUpdate") { + this.sendState(); + } + } + + /** + * Handles messages sent by BackupUIChild. + * + * @param {ReceiveMessageArgument} message + * The message received from the BackupUIChild. + */ + receiveMessage(message) { + if (message.name == "RequestState") { + this.sendState(); + } + } + + /** + * Sends the StateUpdate message to the BackupUIChild, along with the most + * recent state object from BackupService. + */ + sendState() { + this.sendAsyncMessage("StateUpdate", { state: this.#bs.state }); + } +} diff --git a/browser/components/backup/content/BackupManifest.1.schema.json b/browser/components/backup/content/BackupManifest.1.schema.json new file mode 100644 index 0000000000..51418988fe --- /dev/null +++ b/browser/components/backup/content/BackupManifest.1.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///BackupManifest.schema.json", + "title": "BackupManifest", + "description": "A schema for the backup-manifest.json file for profile backups created by the BackupService", + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "Version of the backup manifest structure" + }, + "meta": { + "type": "object", + "description": "Metadata about the backup", + "properties": { + "date": { + "type": "string", + "format": "date-time", + "description": "Date and time that the backup was created" + }, + "appName": { + "type": "string", + "description": "Name of the application that the backup was created for." + }, + "appVersion": { + "type": "string", + "description": "Full version string for the app instance that the backup was created on" + }, + "buildID": { + "type": "string", + "description": "Build ID for the app instance that the backup was created on" + }, + "profileName": { + "type": "string", + "description": "The name given to the profile that was backed up" + }, + "machineName": { + "type": "string", + "description": "The name of the machine that the backup was created on" + }, + "osName": { + "type": "string", + "description": "The OS name that the backup was created on" + }, + "osVersion": { + "type": "string", + "description": "The OS version that the backup was created on" + }, + "accountID": { + "type": "string", + "description": "The ID for the account that the user profile was signed into when backing up. Optional." + }, + "accountEmail": { + "type": "string", + "description": "The email address for the account that the user profile was signed into when backing up. Optional." + }, + "legacyClientID": { + "type": "string", + "description": "The legacy telemetry client ID for the profile that the backup was created on." + } + }, + "required": [ + "date", + "appName", + "appVersion", + "buildID", + "profileName", + "machineName", + "osName", + "osVersion", + "legacyClientID" + ] + }, + "resources": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["version", "resources", "meta"] +} diff --git a/browser/components/backup/content/backup-settings.mjs b/browser/components/backup/content/backup-settings.mjs new file mode 100644 index 0000000000..c34d87dbc7 --- /dev/null +++ b/browser/components/backup/content/backup-settings.mjs @@ -0,0 +1,47 @@ +/* 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"; + +/** + * The widget for managing the BackupService that is embedded within the main + * document of about:settings / about:preferences. + */ +export default class BackupSettings extends MozLitElement { + static properties = { + backupServiceState: { type: Object }, + }; + + /** + * Creates a BackupSettings instance and sets the initial default + * state. + */ + constructor() { + super(); + this.backupServiceState = { + backupInProgress: false, + }; + } + + /** + * Dispatches the BackupUI:InitWidget custom event upon being attached to the + * DOM, which registers with BackupUIChild for BackupService state updates. + */ + connectedCallback() { + super.connectedCallback(); + this.dispatchEvent( + new CustomEvent("BackupUI:InitWidget", { bubbles: true }) + ); + } + + render() { + return html`<div> + Backup in progress: + ${this.backupServiceState.backupInProgress ? "Yes" : "No"} + </div>`; + } +} + +customElements.define("backup-settings", BackupSettings); diff --git a/browser/components/backup/content/backup-settings.stories.mjs b/browser/components/backup/content/backup-settings.stories.mjs new file mode 100644 index 0000000000..2a87c361bc --- /dev/null +++ b/browser/components/backup/content/backup-settings.stories.mjs @@ -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/. */ + +// eslint-disable-next-line import/no-unresolved +import { html } from "lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./backup-settings.mjs"; + +export default { + title: "Domain-specific UI Widgets/Backup/Backup Settings", + component: "backup-settings", + argTypes: {}, +}; + +const Template = ({ backupServiceState }) => html` + <backup-settings .backupServiceState=${backupServiceState}></backup-settings> +`; + +export const BackingUpNotInProgress = Template.bind({}); +BackingUpNotInProgress.args = { + backupServiceState: { + backupInProgress: false, + }, +}; + +export const BackingUpInProgress = Template.bind({}); +BackingUpInProgress.args = { + backupServiceState: { + backupInProgress: true, + }, +}; diff --git a/browser/components/backup/content/debug.html b/browser/components/backup/content/debug.html index 5d6517cf2a..55034d4a5c 100644 --- a/browser/components/backup/content/debug.html +++ b/browser/components/backup/content/debug.html @@ -36,7 +36,25 @@ <section id="controls"> <h2>Controls</h2> <button id="create-backup">Create backup</button> + <p> + Clicking "Create backup" will create a backup, and then attempt to + show an OS notification with the total time it took to create it. This + notification may not appear if your OS has not granted the browser to + display notifications. + </p> + <p id="last-backup-status"></p> <button id="open-backup-folder">Open backups folder</button> + <button id="recover-from-staging"> + Recover from staging folder and launch + </button> + <p> + Clicking "Recover from staging folder and launch" will open a file + picker to allow you to select a staging folder. Once selected, a new + user profile will be created and the data stores from the staging + folder will be copied into that new profile. The new profile will then + be launched. + </p> + <p id="last-recovery-status"></p> </section> </main> diff --git a/browser/components/backup/content/debug.js b/browser/components/backup/content/debug.js index fd673818c0..7a2cea9640 100644 --- a/browser/components/backup/content/debug.js +++ b/browser/components/backup/content/debug.js @@ -26,13 +26,31 @@ let DebugUI = { } }, + secondsToHms(seconds) { + let h = Math.floor(seconds / 3600); + let m = Math.floor((seconds % 3600) / 60); + let s = Math.floor((seconds % 3600) % 60); + return `${h}h ${m}m ${s}s`; + }, + async onButtonClick(button) { switch (button.id) { case "create-backup": { let service = BackupService.get(); + let lastBackupStatus = document.querySelector("#last-backup-status"); + lastBackupStatus.textContent = "Creating backup..."; + + let then = Cu.now(); button.disabled = true; await service.createBackup(); + let totalTimeSeconds = (Cu.now() - then) / 1000; button.disabled = false; + new Notification(`Backup created`, { + body: `Total time ${this.secondsToHms(totalTimeSeconds)}`, + }); + lastBackupStatus.textContent = `Backup created - total time: ${this.secondsToHms( + totalTimeSeconds + )}`; break; } case "open-backup-folder": { @@ -52,6 +70,42 @@ let DebugUI = { break; } + case "recover-from-staging": { + let backupsDir = PathUtils.join(PathUtils.profileDir, "backups"); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + fp.init( + window.browsingContext, + "Choose a staging folder", + Ci.nsIFilePicker.modeGetFolder + ); + fp.displayDirectory = await IOUtils.getDirectory(backupsDir); + let result = await new Promise(resolve => fp.open(resolve)); + if (result == Ci.nsIFilePicker.returnCancel) { + break; + } + + let path = fp.file.path; + let lastRecoveryStatus = document.querySelector( + "#last-recovery-status" + ); + lastRecoveryStatus.textContent = "Recovering from backup..."; + + let service = BackupService.get(); + try { + let newProfile = await service.recoverFromBackup( + path, + true /* shouldLaunch */ + ); + lastRecoveryStatus.textContent = `Created profile ${newProfile.name} at ${newProfile.rootDir.path}`; + } catch (e) { + lastRecoveryStatus.textContent( + `Failed to recover: ${e.message} Check the console for the full exception.` + ); + throw e; + } + } } }, }; diff --git a/browser/components/backup/docs/backup-ui-actors.rst b/browser/components/backup/docs/backup-ui-actors.rst new file mode 100644 index 0000000000..eafe59d05b --- /dev/null +++ b/browser/components/backup/docs/backup-ui-actors.rst @@ -0,0 +1,22 @@ +========================== +Backup UI Actors Reference +========================== + +The ``BackupUIParent`` and ``BackupUIChild`` actors allow UI widgets to access +the current state of the ``BackupService`` and to subscribe to state updates. + +UI widgets that want to subscribe to state updates must ensure that they are +running in a process and on a page that the ``BackupUIParent/BackupUIChild`` +actor pair are registered for, and then fire a ``BackupUI::InitWidget`` event. + +It is expected that these UI widgets will respond to having their +``backupServiceState`` property set. + +.. js:autoclass:: BackupUIParent + :members: + :private-members: + +.. js:autoclass:: BackupUIChild +.. js::autoattribute:: BackupUIChild#inittedWidgets + :members: + :private-members: diff --git a/browser/components/backup/docs/index.rst b/browser/components/backup/docs/index.rst index db9995dad2..fc8751f9d2 100644 --- a/browser/components/backup/docs/index.rst +++ b/browser/components/backup/docs/index.rst @@ -12,3 +12,4 @@ into a single file that can be easily restored from. backup-service backup-resources + backup-ui-actors diff --git a/browser/components/backup/jar.mn b/browser/components/backup/jar.mn index 7800962486..94c670afaf 100644 --- a/browser/components/backup/jar.mn +++ b/browser/components/backup/jar.mn @@ -7,3 +7,5 @@ browser.jar: content/browser/backup/debug.html (content/debug.html) content/browser/backup/debug.js (content/debug.js) #endif + content/browser/backup/BackupManifest.1.schema.json (content/BackupManifest.1.schema.json) + content/browser/backup/backup-settings.mjs (content/backup-settings.mjs) diff --git a/browser/components/backup/moz.build b/browser/components/backup/moz.build index be548ce81f..853ae1d80d 100644 --- a/browser/components/backup/moz.build +++ b/browser/components/backup/moz.build @@ -12,6 +12,14 @@ JAR_MANIFESTS += ["jar.mn"] SPHINX_TREES["docs"] = "docs" XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] +MARIONETTE_MANIFESTS += ["tests/marionette/manifest.toml"] +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] + +FINAL_TARGET_FILES.actors += [ + "actors/BackupUIChild.sys.mjs", + "actors/BackupUIParent.sys.mjs", +] EXTRA_JS_MODULES.backup += [ "BackupResources.sys.mjs", diff --git a/browser/components/backup/resources/AddonsBackupResource.sys.mjs b/browser/components/backup/resources/AddonsBackupResource.sys.mjs index 83b97ed2f2..29b51b8a7f 100644 --- a/browser/components/backup/resources/AddonsBackupResource.sys.mjs +++ b/browser/components/backup/resources/AddonsBackupResource.sys.mjs @@ -16,6 +16,73 @@ export class AddonsBackupResource extends BackupResource { return false; } + async backup(stagingPath, profilePath = PathUtils.profileDir) { + // Files and directories to backup. + let toCopy = [ + "extensions.json", + "extension-settings.json", + "extension-preferences.json", + "addonStartup.json.lz4", + "browser-extension-data", + "extension-store-permissions", + ]; + await BackupResource.copyFiles(profilePath, stagingPath, toCopy); + + // Backup only the XPIs in the extensions directory. + let xpiFiles = []; + let extensionsXPIDirectoryPath = PathUtils.join(profilePath, "extensions"); + let xpiDirectoryChildren = await IOUtils.getChildren( + extensionsXPIDirectoryPath, + { + ignoreAbsent: true, + } + ); + for (const childFilePath of xpiDirectoryChildren) { + if (childFilePath.endsWith(".xpi")) { + let childFileName = PathUtils.filename(childFilePath); + xpiFiles.push(childFileName); + } + } + // Create the extensions directory in the staging directory. + let stagingExtensionsXPIDirectoryPath = PathUtils.join( + stagingPath, + "extensions" + ); + await IOUtils.makeDirectory(stagingExtensionsXPIDirectoryPath); + // Copy all found XPIs to the staging directory. + await BackupResource.copyFiles( + extensionsXPIDirectoryPath, + stagingExtensionsXPIDirectoryPath, + xpiFiles + ); + + // Copy storage sync database. + let databases = ["storage-sync-v2.sqlite"]; + await BackupResource.copySqliteDatabases( + profilePath, + stagingPath, + databases + ); + + return null; + } + + async recover(_manifestEntry, recoveryPath, destProfilePath) { + const files = [ + "extensions.json", + "extension-settings.json", + "extension-preferences.json", + "addonStartup.json.lz4", + "browser-extension-data", + "extension-store-permissions", + "extensions", + "storage-sync-v2.sqlite", + ]; + await BackupResource.copyFiles(recoveryPath, destProfilePath, files); + + return null; + } + async measure(profilePath = PathUtils.profileDir) { // Report the total size of the extension json files. const jsonFiles = [ @@ -55,16 +122,16 @@ export class AddonsBackupResource extends BackupResource { 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, + 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 + extensionsXPIDirectorySize ); // Report the total size of the browser extension data. diff --git a/browser/components/backup/resources/BackupResource.sys.mjs b/browser/components/backup/resources/BackupResource.sys.mjs index d851eb5199..5be6314a60 100644 --- a/browser/components/backup/resources/BackupResource.sys.mjs +++ b/browser/components/backup/resources/BackupResource.sys.mjs @@ -2,6 +2,14 @@ * 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + // Convert from bytes to kilobytes (not kibibytes). export const BYTES_IN_KB = 1000; @@ -50,6 +58,19 @@ export class BackupResource { } /** + * This can be overridden to return a number indicating the priority the + * resource should have in the backup order. + * + * Resources with a higher priority will be backed up first. + * The default priority of 0 indicates it can be processed in any order. + * + * @returns {number} + */ + static get priority() { + return 0; + } + + /** * Get the size of a file. * * @param {string} filePath - path to a file. @@ -129,6 +150,75 @@ export class BackupResource { return size; } + /** + * Copy a set of SQLite databases safely from a source directory to a + * destination directory. A new read-only connection is opened for each + * database, and then a backup is created. If the source database does not + * exist, it is ignored. + * + * @param {string} sourcePath + * Path to the source directory of the SQLite databases. + * @param {string} destPath + * Path to the destination directory where the SQLite databases should be + * copied to. + * @param {Array<string>} sqliteDatabases + * An array of filenames of the SQLite databases to copy. + * @returns {Promise<undefined>} + */ + static async copySqliteDatabases(sourcePath, destPath, sqliteDatabases) { + for (let fileName of sqliteDatabases) { + let sourceFilePath = PathUtils.join(sourcePath, fileName); + + if (!(await IOUtils.exists(sourceFilePath))) { + continue; + } + + let destFilePath = PathUtils.join(destPath, fileName); + let connection; + + try { + connection = await lazy.Sqlite.openConnection({ + path: sourceFilePath, + readOnly: true, + }); + + await connection.backup( + destFilePath, + BackupResource.SQLITE_PAGES_PER_STEP, + BackupResource.SQLITE_STEP_DELAY_MS + ); + } finally { + await connection?.close(); + } + } + } + + /** + * A helper function to copy a set of files from a source directory to a + * destination directory. Callers should ensure that the source files can be + * copied safely before invoking this function. Files that do not exist will + * be ignored. Callers that wish to copy SQLite databases should use + * copySqliteDatabases() instead. + * + * @param {string} sourcePath + * Path to the source directory of the files to be copied. + * @param {string} destPath + * Path to the destination directory where the files should be + * copied to. + * @param {string[]} fileNames + * An array of filenames of the files to copy. + * @returns {Promise<undefined>} + */ + static async copyFiles(sourcePath, destPath, fileNames) { + for (let fileName of fileNames) { + let sourceFilePath = PathUtils.join(sourcePath, fileName); + let destFilePath = PathUtils.join(destPath, fileName); + if (await IOUtils.exists(sourceFilePath)) { + await IOUtils.copy(sourceFilePath, destFilePath, { recursive: true }); + } + } + } + constructor() {} /** @@ -144,12 +234,12 @@ export class BackupResource { } /** - * 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. + * Perform a safe copy of the datastores that this resource manages 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 @@ -167,4 +257,72 @@ export class BackupResource { async backup(stagingPath, profilePath = null) { throw new Error("BackupResource::backup must be overridden"); } + + /** + * Recovers the datastores that this resource manages from a backup archive + * that has been decompressed into the recoveryPath. A pre-existing unlocked + * user profile should be available to restore into, and destProfilePath + * should point at its location on the file system. + * + * This method is not expected to be running in an app connected to the + * destProfilePath. If the BackupResource needs to run some operations + * while attached to the recovery profile, it should do that work inside of + * postRecovery(). If data needs to be transferred to postRecovery(), it + * should be passed as a JSON serializable object in the return value of this + * method. + * + * @see BackupResource.postRecovery() + * @param {object|null} manifestEntry + * The object that was returned by the backup() method when the backup was + * created. This object can be null if no additional information was needed + * for recovery. + * @param {string} recoveryPath + * The path to the resource directory where the backup archive has been + * decompressed. + * @param {string} destProfilePath + * The path to the profile directory where the backup should be restored to. + * @returns {Promise<object|null>} + * This should return a JSON serializable object that will be passed to + * postRecovery() if any data needs to be passed to it. This object can be + * null if no additional information is needed for postRecovery(). + */ + // eslint-disable-next-line no-unused-vars + async recover(manifestEntry, recoveryPath, destProfilePath) { + throw new Error("BackupResource::recover must be overridden"); + } + + /** + * Perform any post-recovery operations that need to be done after the + * recovery has been completed and the recovered profile has been attached + * to. + * + * This method is running in an app connected to the recovered profile. The + * profile is locked, but this postRecovery method can be used to insert + * data into connected datastores, or perform any other operations that can + * only occur within the context of the recovered profile. + * + * @see BackupResource.recover() + * @param {object|null} postRecoveryEntry + * The object that was returned by the recover() method when the recovery + * was originally done. This object can be null if no additional information + * is needed for post-recovery. + */ + // eslint-disable-next-line no-unused-vars + async postRecovery(postRecoveryEntry) { + // no-op by default + } } + +XPCOMUtils.defineLazyPreferenceGetter( + BackupResource, + "SQLITE_PAGES_PER_STEP", + "browser.backup.sqlite.pages_per_step", + 5 +); + +XPCOMUtils.defineLazyPreferenceGetter( + BackupResource, + "SQLITE_STEP_DELAY_MS", + "browser.backup.sqlite.step_delay_ms", + 250 +); diff --git a/browser/components/backup/resources/CookiesBackupResource.sys.mjs b/browser/components/backup/resources/CookiesBackupResource.sys.mjs index 8b988fd532..87ac27757c 100644 --- a/browser/components/backup/resources/CookiesBackupResource.sys.mjs +++ b/browser/components/backup/resources/CookiesBackupResource.sys.mjs @@ -16,6 +16,20 @@ export class CookiesBackupResource extends BackupResource { return true; } + async backup(stagingPath, profilePath = PathUtils.profileDir) { + await BackupResource.copySqliteDatabases(profilePath, stagingPath, [ + "cookies.sqlite", + ]); + return null; + } + + async recover(_manifestEntry, recoveryPath, destProfilePath) { + await BackupResource.copyFiles(recoveryPath, destProfilePath, [ + "cookies.sqlite", + ]); + return null; + } + async measure(profilePath = PathUtils.profileDir) { let cookiesDBPath = PathUtils.join(profilePath, "cookies.sqlite"); let cookiesSize = await BackupResource.getFileSize(cookiesDBPath); diff --git a/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs b/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs index 89069de826..03a0267f33 100644 --- a/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs +++ b/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs @@ -16,6 +16,42 @@ export class CredentialsAndSecurityBackupResource extends BackupResource { return true; } + async backup(stagingPath, profilePath = PathUtils.profileDir) { + const simpleCopyFiles = [ + "pkcs11.txt", + "logins.json", + "logins-backup.json", + "autofill-profiles.json", + "signedInUser.json", + ]; + await BackupResource.copyFiles(profilePath, stagingPath, simpleCopyFiles); + + const sqliteDatabases = ["cert9.db", "key4.db", "credentialstate.sqlite"]; + await BackupResource.copySqliteDatabases( + profilePath, + stagingPath, + sqliteDatabases + ); + + return null; + } + + async recover(_manifestEntry, recoveryPath, destProfilePath) { + const files = [ + "pkcs11.txt", + "logins.json", + "logins-backup.json", + "autofill-profiles.json", + "signedInUser.json", + "cert9.db", + "key4.db", + "credentialstate.sqlite", + ]; + await BackupResource.copyFiles(recoveryPath, destProfilePath, files); + + return null; + } + async measure(profilePath = PathUtils.profileDir) { const securityFiles = ["cert9.db", "pkcs11.txt"]; let securitySize = 0; diff --git a/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs b/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs index cb314eb34d..8e35afc66b 100644 --- a/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs +++ b/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs @@ -16,6 +16,22 @@ export class FormHistoryBackupResource extends BackupResource { return false; } + async backup(stagingPath, profilePath = PathUtils.profileDir) { + await BackupResource.copySqliteDatabases(profilePath, stagingPath, [ + "formhistory.sqlite", + ]); + + return null; + } + + async recover(_manifestEntry, recoveryPath, destProfilePath) { + await BackupResource.copyFiles(recoveryPath, destProfilePath, [ + "formhistory.sqlite", + ]); + + return null; + } + async measure(profilePath = PathUtils.profileDir) { let formHistoryDBPath = PathUtils.join(profilePath, "formhistory.sqlite"); let formHistorySize = await BackupResource.getFileSize(formHistoryDBPath); diff --git a/browser/components/backup/resources/MiscDataBackupResource.sys.mjs b/browser/components/backup/resources/MiscDataBackupResource.sys.mjs index 97224f0e31..3d66114599 100644 --- a/browser/components/backup/resources/MiscDataBackupResource.sys.mjs +++ b/browser/components/backup/resources/MiscDataBackupResource.sys.mjs @@ -5,11 +5,19 @@ import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; const lazy = {}; - ChromeUtils.defineESModuleGetters(lazy, { - Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + ActivityStreamStorage: + "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", }); +const SNIPPETS_TABLE_NAME = "snippets"; +const FILES_FOR_BACKUP = [ + "enumerate_devices.txt", + "protections.sqlite", + "SiteSecurityServiceState.bin", +]; + /** * Class representing miscellaneous files for telemetry, site storage, * media device origin mapping, chrome privileged IndexedDB databases, @@ -25,57 +33,102 @@ export class MiscDataBackupResource extends BackupResource { } 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 files = ["enumerate_devices.txt", "SiteSecurityServiceState.bin"]; + await BackupResource.copyFiles(profilePath, stagingPath, files); 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(); - } - } + await BackupResource.copySqliteDatabases( + profilePath, + stagingPath, + sqliteDatabases + ); // 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. + // chrome-privileged IndexedDB databases under storage/permanent/chrome. + // Instead, we'll manually export any IndexedDB data we need to backup + // to a separate JSON file. + + // The first IndexedDB database we want to back up is the ActivityStream + // one - specifically, the "snippets" table, as this contains information + // on ASRouter impressions, blocked messages, message group impressions, + // etc. + let storage = new lazy.ActivityStreamStorage({ + storeNames: [SNIPPETS_TABLE_NAME], + }); + let snippetsTable = await storage.getDbTable(SNIPPETS_TABLE_NAME); + let snippetsObj = {}; + for (let key of await snippetsTable.getAllKeys()) { + snippetsObj[key] = await snippetsTable.get(key); + } + let snippetsBackupFile = PathUtils.join( + stagingPath, + "activity-stream-snippets.json" + ); + await IOUtils.writeJSON(snippetsBackupFile, snippetsObj); return null; } - async measure(profilePath = PathUtils.profileDir) { - const files = [ + async recover(_manifestEntry, recoveryPath, destProfilePath) { + await BackupResource.copyFiles( + recoveryPath, + destProfilePath, + FILES_FOR_BACKUP + ); + + // The times.json file, the one that powers ProfileAge, works hand in hand + // with the Telemetry client ID. We don't want to accidentally _overwrite_ + // a pre-existing times.json with data from a different profile, because + // then the client ID wouldn't match the times.json data anymore. + // + // The rule that we're following for backups and recoveries is that the + // recovered profile always inherits the client ID (and therefore the + // times.json) from the profile that _initiated recovery_. + // + // This means we want to copy the times.json file from the profile that's + // currently in use to the destProfilePath. + await BackupResource.copyFiles(PathUtils.profileDir, destProfilePath, [ "times.json", - "enumerate_devices.txt", - "protections.sqlite", - "SiteSecurityServiceState.bin", - ]; + ]); + + // We also want to write the recoveredFromBackup timestamp now. + let profileAge = await lazy.ProfileAge(destProfilePath); + await profileAge.recordRecoveredFromBackup(); + + // The activity-stream-snippets data will need to be written during the + // postRecovery phase, so we'll stash the path to the JSON file in the + // post recovery entry. + let snippetsBackupFile = PathUtils.join( + recoveryPath, + "activity-stream-snippets.json" + ); + return { snippetsBackupFile }; + } + + async postRecovery(postRecoveryEntry) { + let { snippetsBackupFile } = postRecoveryEntry; + // If for some reason, the activity-stream-snippets data file has been + // removed already, there's nothing to do. + if (!IOUtils.exists(snippetsBackupFile)) { + return; + } + + let snippetsData = await IOUtils.readJSON(snippetsBackupFile); + let storage = new lazy.ActivityStreamStorage({ + storeNames: [SNIPPETS_TABLE_NAME], + }); + let snippetsTable = await storage.getDbTable(SNIPPETS_TABLE_NAME); + for (let key in snippetsData) { + let value = snippetsData[key]; + await snippetsTable.set(key, value); + } + } + + async measure(profilePath = PathUtils.profileDir) { let fullSize = 0; - for (let filePath of files) { + for (let filePath of FILES_FOR_BACKUP) { let resourcePath = PathUtils.join(profilePath, filePath); let resourceSize = await BackupResource.getFileSize(resourcePath); if (Number.isInteger(resourceSize)) { diff --git a/browser/components/backup/resources/PlacesBackupResource.sys.mjs b/browser/components/backup/resources/PlacesBackupResource.sys.mjs index 1955406f51..3a9433e67c 100644 --- a/browser/components/backup/resources/PlacesBackupResource.sys.mjs +++ b/browser/components/backup/resources/PlacesBackupResource.sys.mjs @@ -10,7 +10,6 @@ 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( @@ -26,6 +25,8 @@ XPCOMUtils.defineLazyPreferenceGetter( false ); +const BOOKMARKS_BACKUP_FILENAME = "bookmarks.jsonlz4"; + /** * Class representing Places database related files within a user profile. */ @@ -38,8 +39,11 @@ export class PlacesBackupResource extends BackupResource { return false; } + static get priority() { + return 1; + } + async backup(stagingPath, profilePath = PathUtils.profileDir) { - const sqliteDatabases = ["places.sqlite", "favicons.sqlite"]; let canBackupHistory = !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing && !lazy.isSanitizeOnShutdownEnabled && @@ -52,7 +56,7 @@ export class PlacesBackupResource extends BackupResource { if (!canBackupHistory) { let bookmarksBackupFile = PathUtils.join( stagingPath, - "bookmarks.jsonlz4" + BOOKMARKS_BACKUP_FILENAME ); await lazy.BookmarkJSONUtils.exportToFile(bookmarksBackupFile, { compress: true, @@ -60,25 +64,60 @@ export class PlacesBackupResource extends BackupResource { return { bookmarksOnly: true }; } - for (let fileName of sqliteDatabases) { - let sourcePath = PathUtils.join(profilePath, fileName); - let destPath = PathUtils.join(stagingPath, fileName); - let connection; + // These are copied in parallel because they're attached[1], and we don't + // want them to get out of sync with one another. + // + // [1]: https://www.sqlite.org/lang_attach.html + await Promise.all([ + BackupResource.copySqliteDatabases(profilePath, stagingPath, [ + "places.sqlite", + ]), + BackupResource.copySqliteDatabases(profilePath, stagingPath, [ + "favicons.sqlite", + ]), + ]); + + return null; + } - try { - connection = await lazy.Sqlite.openConnection({ - path: sourcePath, - readOnly: true, - }); + async recover(manifestEntry, recoveryPath, destProfilePath) { + if (!manifestEntry) { + const simpleCopyFiles = ["places.sqlite", "favicons.sqlite"]; + await BackupResource.copyFiles( + recoveryPath, + destProfilePath, + simpleCopyFiles + ); + } else { + const { bookmarksOnly } = manifestEntry; - await connection.backup(destPath); - } finally { - await connection.close(); + /** + * If the recovery file only has bookmarks backed up, pass the file path to postRecovery() + * so that we can import all bookmarks into the new profile once it's been launched and restored. + */ + if (bookmarksOnly) { + let bookmarksBackupPath = PathUtils.join( + recoveryPath, + BOOKMARKS_BACKUP_FILENAME + ); + return { bookmarksBackupPath }; } } + return null; } + async postRecovery(postRecoveryEntry) { + if (postRecoveryEntry?.bookmarksBackupPath) { + await lazy.BookmarkJSONUtils.importFromFile( + postRecoveryEntry.bookmarksBackupPath, + { + replace: true, + } + ); + } + } + async measure(profilePath = PathUtils.profileDir) { let placesDBPath = PathUtils.join(profilePath, "places.sqlite"); let faviconsDBPath = PathUtils.join(profilePath, "favicons.sqlite"); diff --git a/browser/components/backup/resources/PreferencesBackupResource.sys.mjs b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs index 012c0bf91e..80196cab74 100644 --- a/browser/components/backup/resources/PreferencesBackupResource.sys.mjs +++ b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs @@ -3,7 +3,6 @@ * 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. @@ -28,32 +27,14 @@ export class PreferencesBackupResource extends BackupResource { "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 }); - } - } + await BackupResource.copyFiles(profilePath, stagingPath, simpleCopyFiles); 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(); - } - } + await BackupResource.copySqliteDatabases( + profilePath, + stagingPath, + sqliteDatabases + ); // prefs.js is a special case - we have a helper function to flush the // current prefs state to disk off of the main thread. @@ -64,6 +45,27 @@ export class PreferencesBackupResource extends BackupResource { return null; } + async recover(_manifestEntry, recoveryPath, destProfilePath) { + const simpleCopyFiles = [ + "prefs.js", + "xulstore.json", + "permissions.sqlite", + "content-prefs.sqlite", + "containers.json", + "handlers.json", + "search.json.mozlz4", + "user.js", + "chrome", + ]; + await BackupResource.copyFiles( + recoveryPath, + destProfilePath, + simpleCopyFiles + ); + + return null; + } + async measure(profilePath = PathUtils.profileDir) { const files = [ "prefs.js", diff --git a/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs index fa5dcca848..d28598944f 100644 --- a/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs +++ b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs @@ -28,6 +28,32 @@ export class SessionStoreBackupResource extends BackupResource { return false; } + async backup(stagingPath, profilePath = PathUtils.profileDir) { + let sessionStoreState = lazy.SessionStore.getCurrentState(true); + let sessionStorePath = PathUtils.join(stagingPath, "sessionstore.jsonlz4"); + + /* Bug 1891854 - remove cookies from session store state if the backup file is + * not encrypted. */ + + await IOUtils.writeJSON(sessionStorePath, sessionStoreState, { + compress: true, + }); + await BackupResource.copyFiles(profilePath, stagingPath, [ + "sessionstore-backups", + ]); + + return null; + } + + async recover(_manifestEntry, recoveryPath, destProfilePath) { + await BackupResource.copyFiles(recoveryPath, destProfilePath, [ + "sessionstore.jsonlz4", + "sessionstore-backups", + ]); + + return null; + } + async measure(profilePath = PathUtils.profileDir) { // Get the current state of the session store JSON and // measure it's uncompressed size. diff --git a/browser/components/backup/tests/browser/browser.toml b/browser/components/backup/tests/browser/browser.toml new file mode 100644 index 0000000000..f222c3b825 --- /dev/null +++ b/browser/components/backup/tests/browser/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] +prefs = [ + "browser.backup.enabled=true", + "browser.backup.preferences.ui.enabled=true", +] + +["browser_settings.js"] diff --git a/browser/components/backup/tests/browser/browser_settings.js b/browser/components/backup/tests/browser/browser_settings.js new file mode 100644 index 0000000000..b33dbec7bd --- /dev/null +++ b/browser/components/backup/tests/browser/browser_settings.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the section for controlling backup in about:preferences is + * visible, but can also be hidden via a pref. + */ +add_task(async function test_preferences_visibility() { + await BrowserTestUtils.withNewTab("about:preferences", async browser => { + let backupSection = + browser.contentDocument.querySelector("#dataBackupGroup"); + Assert.ok(backupSection, "Found backup preferences section"); + + // Our mochitest-browser tests are configured to have the section visible + // by default. + Assert.ok( + BrowserTestUtils.isVisible(backupSection), + "Backup section is visible" + ); + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.backup.preferences.ui.enabled", false]], + }); + + await BrowserTestUtils.withNewTab("about:preferences", async browser => { + let backupSection = + browser.contentDocument.querySelector("#dataBackupGroup"); + Assert.ok(backupSection, "Found backup preferences section"); + + Assert.ok( + BrowserTestUtils.isHidden(backupSection), + "Backup section is now hidden" + ); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/backup/tests/chrome/chrome.toml b/browser/components/backup/tests/chrome/chrome.toml new file mode 100644 index 0000000000..b0c01b336f --- /dev/null +++ b/browser/components/backup/tests/chrome/chrome.toml @@ -0,0 +1,4 @@ +[DEFAULT] +skip-if = ["os == 'android'"] + +["test_backup_settings.html"] diff --git a/browser/components/backup/tests/chrome/test_backup_settings.html b/browser/components/backup/tests/chrome/test_backup_settings.html new file mode 100644 index 0000000000..3619f8a1f4 --- /dev/null +++ b/browser/components/backup/tests/chrome/test_backup_settings.html @@ -0,0 +1,43 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests for the BackupSettings component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script + src="chrome://browser/content/backup/backup-settings.mjs" + type="module" +></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script> + + const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + /** + * Tests that adding a backup-settings element to the DOM causes it to + * fire a BackupUI:InitWidget event. + */ + add_task(async function test_initWidget() { + let settings = document.createElement("backup-settings"); + let content = document.getElementById("content"); + + let sawInitWidget = BrowserTestUtils.waitForEvent(content, "BackupUI:InitWidget"); + content.appendChild(settings); + await sawInitWidget; + ok(true, "Saw BackupUI:InitWidget"); + + settings.remove(); + }); + </script> +</head> +<body> +<p id="display"></p> +<div id="content" style="display: none"> + <backup-settings id="test-backup-settings"></backup-settings> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/browser/components/backup/tests/marionette/http2-ca.pem b/browser/components/backup/tests/marionette/http2-ca.pem new file mode 100644 index 0000000000..ef5a801720 --- /dev/null +++ b/browser/components/backup/tests/marionette/http2-ca.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC1DCCAbygAwIBAgIURZvN7yVqFNwThGHASoy1OlOGvOMwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOIEhUVFAyIFRlc3QgQ0EwIhgPMjAxNzAxMDEwMDAwMDBa +GA8yMDI3MDEwMTAwMDAwMFowGTEXMBUGA1UEAwwOIEhUVFAyIFRlc3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6iFGoRI4W1kH9braIBjYQPTwT +2erkNUq07PVoV2wke8HHJajg2B+9sZwGm24ahvJr4q9adWtqZHEIeqVap0WH9xzV +JJwCfs1D/B5p0DggKZOrIMNJ5Nu5TMJrbA7tFYIP8X6taRqx0wI6iypB7qdw4A8N +jf1mCyuwJJKkfbmIYXmQsVeQPdI7xeC4SB+oN9OIQ+8nFthVt2Zaqn4CkC86exCA +BiTMHGyXrZZhW7filhLAdTGjDJHdtMr3/K0dJdMJ77kXDqdo4bN7LyJvaeO0ipVh +He4m1iWdq5EITjbLHCQELL8Wiy/l8Y+ZFzG4s/5JI/pyUcQx1QOs2hgKNe2NAgMB +AAGjEDAOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADyDiQnKjsvR +NrOk0aqgJ8XgK/IgJXFLbAVivjBLwnJGEkwxrFtC14mpTrPuXw9AybhroMjinq4Y +cNYTFuTE34k0fZEU8d60J/Tpfd1i0EB8+oUPuqOn+N29/LeHPAnkDJdOZye3w0U+ +StAI79WqUYQaKIG7qLnt60dQwBte12uvbuPaB3mREIfDXOKcjLBdZHL1waWjtzUX +z2E91VIdpvJGfEfXC3fIe1uO9Jh/E9NVWci84+njkNsl+OyBfOJ8T+pV3SHfWedp +Zbjwh6UTukIuc3mW0rS/qZOa2w3HQaO53BMbluo0w1+cscOepsATld2HHvSiHB+0 +K8SWFRHdBOU= +-----END CERTIFICATE----- diff --git a/browser/components/backup/tests/marionette/manifest.toml b/browser/components/backup/tests/marionette/manifest.toml new file mode 100644 index 0000000000..2982adb693 --- /dev/null +++ b/browser/components/backup/tests/marionette/manifest.toml @@ -0,0 +1,6 @@ +[DEFAULT] +run-if = ["buildapp == 'browser'"] +prefs = ["browser.backup.enabled=true", "browser.backup.log=true"] + +["test_backup.py"] +support-files = ["http2-ca.pem"] diff --git a/browser/components/backup/tests/marionette/test_backup.py b/browser/components/backup/tests/marionette/test_backup.py new file mode 100644 index 0000000000..3b11b50ae8 --- /dev/null +++ b/browser/components/backup/tests/marionette/test_backup.py @@ -0,0 +1,713 @@ +# 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 json +import os +import shutil +import tempfile + +import mozfile +from marionette_harness import MarionetteTestCase + + +class BackupTest(MarionetteTestCase): + # This is the DB key that will be computed for the http2-ca.pem certificate + # that's included in a support-file for this test. + _cert_db_key = "AAAAAAAAAAAAAAAUAAAAG0Wbze8lahTcE4RhwEqMtTpThrzjMBkxFzAVBgNVBAMMDiBIVFRQMiBUZXN0IENB" + + def setUp(self): + MarionetteTestCase.setUp(self) + # We need to quit the browser and restart with the browser.backup.log + # pref already set to true in order for it to be displayed. + self.marionette.quit() + self.marionette.instance.prefs = { + "browser.backup.log": True, + } + # Now restart the browser. + self.marionette.instance.switch_profile() + self.marionette.start_session() + + def test_backup(self): + self.marionette.set_context("chrome") + + self.add_test_cookie() + self.add_test_login() + self.add_test_certificate() + self.add_test_saved_address() + self.add_test_identity_credential() + self.add_test_form_history() + self.add_test_activity_stream_snippets_data() + self.add_test_protections_data() + self.add_test_bookmarks() + self.add_test_history() + self.add_test_preferences() + self.add_test_permissions() + + resourceKeys = self.marionette.execute_script( + """ + const DefaultBackupResources = ChromeUtils.importESModule("resource:///modules/backup/BackupResources.sys.mjs"); + let resourceKeys = []; + for (const resourceName in DefaultBackupResources) { + let resource = DefaultBackupResources[resourceName]; + resourceKeys.push(resource.key); + } + return resourceKeys; + """ + ) + + originalStagingPath = self.marionette.execute_async_script( + """ + const { BackupService } = ChromeUtils.importESModule("resource:///modules/backup/BackupService.sys.mjs"); + let bs = BackupService.init(); + if (!bs) { + throw new Error("Could not get initialized BackupService."); + } + + let [outerResolve] = arguments; + (async () => { + let { stagingPath } = await bs.createBackup(); + if (!stagingPath) { + throw new Error("Could not create backup."); + } + return stagingPath; + })().then(outerResolve); + """ + ) + + # When we switch over to the recovered profile, the Marionette framework + # will blow away the profile directory of the one that we created the + # backup on, which ruins our ability to do postRecovery work, since + # that relies on the prior profile sticking around. We work around this + # by moving the staging folder we got back to the OS temporary + # directory, and telling the recovery method to use that instead of the + # one from the profile directory. + stagingPath = os.path.join(tempfile.gettempdir(), "staging-test") + # Delete the destination folder if it exists already + shutil.rmtree(stagingPath, ignore_errors=True) + shutil.move(originalStagingPath, stagingPath) + + # First, ensure that the staging path exists + self.assertTrue(os.path.exists(stagingPath)) + # Now, ensure that the backup-manifest.json file exists within it. + manifestPath = os.path.join(stagingPath, "backup-manifest.json") + self.assertTrue(os.path.exists(manifestPath)) + + # For now, we just do a cursory check to ensure that for the resources + # that are listed in the manifest as having been backed up, that we + # have at least one file in their respective staging directories. + # We don't check the contents of the files, just that they exist. + + # Read the JSON manifest file + with open(manifestPath, "r") as f: + manifest = json.load(f) + + # Ensure that the manifest has a "resources" key + self.assertIn("resources", manifest) + resources = manifest["resources"] + self.assertTrue(isinstance(resources, dict)) + self.assertTrue(len(resources) > 0) + + # We don't have encryption capabilities wired up yet, so we'll check + # that all default resources are represented in the manifest. + self.assertEqual(len(resources), len(resourceKeys)) + for resourceKey in resourceKeys: + self.assertIn(resourceKey, resources) + + # Iterate the resources dict keys + for resourceKey in resources: + print("Checking resource: %s" % resourceKey) + # Ensure that there are staging directories created for each + # resource that was backed up + resourceStagingDir = os.path.join(stagingPath, resourceKey) + self.assertTrue(os.path.exists(resourceStagingDir)) + + # Start a brand new profile, one without any of the data we created or + # backed up. This is the one that we'll be starting recovery from. + self.marionette.quit() + self.marionette.instance.profile = None + self.marionette.start_session() + self.marionette.set_context("chrome") + + # Recover the created backup into a new profile directory. Also get out + # the client ID of this profile, because we're going to want to make + # sure that this client ID is inherited by the recovered profile. + [ + newProfileName, + newProfilePath, + expectedClientID, + ] = self.marionette.execute_async_script( + """ + const { ClientID } = ChromeUtils.importESModule("resource://gre/modules/ClientID.sys.mjs"); + const { BackupService } = ChromeUtils.importESModule("resource:///modules/backup/BackupService.sys.mjs"); + let bs = BackupService.get(); + if (!bs) { + throw new Error("Could not get initialized BackupService."); + } + + let [stagingPath, outerResolve] = arguments; + (async () => { + let newProfileRootPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "recoverFromBackupTest-newProfileRoot" + ); + let newProfile = await bs.recoverFromBackup(stagingPath, false, newProfileRootPath) + if (!newProfile) { + throw new Error("Could not create recovery profile."); + } + + let expectedClientID = await ClientID.getClientID(); + + return [newProfile.name, newProfile.rootDir.path, expectedClientID]; + })().then(outerResolve); + """, + script_args=[stagingPath], + ) + + print("Recovery name: %s" % newProfileName) + print("Recovery path: %s" % newProfilePath) + print("Expected clientID: %s" % expectedClientID) + + self.marionette.quit() + originalProfile = self.marionette.instance.profile + self.marionette.instance.profile = newProfilePath + self.marionette.start_session() + self.marionette.set_context("chrome") + + # Ensure that all postRecovery actions have completed. + self.marionette.execute_async_script( + """ + const { BackupService } = ChromeUtils.importESModule("resource:///modules/backup/BackupService.sys.mjs"); + let bs = BackupService.get(); + if (!bs) { + throw new Error("Could not get initialized BackupService."); + } + + let [outerResolve] = arguments; + (async () => { + await bs.postRecoveryComplete; + })().then(outerResolve); + """ + ) + + self.verify_recovered_test_cookie() + self.verify_recovered_test_login() + self.verify_recovered_test_certificate() + self.verify_recovered_saved_address() + self.verify_recovered_identity_credential() + self.verify_recovered_form_history() + self.verify_recovered_activity_stream_snippets_data() + self.verify_recovered_protections_data() + self.verify_recovered_bookmarks() + self.verify_recovered_history() + self.verify_recovered_preferences() + self.verify_recovered_permissions() + + # Now also ensure that the recovered profile inherited the client ID + # from the profile that initiated recovery. + recoveredClientID = self.marionette.execute_async_script( + """ + const { ClientID } = ChromeUtils.importESModule("resource://gre/modules/ClientID.sys.mjs"); + let [outerResolve] = arguments; + (async () => { + return ClientID.getClientID(); + })().then(outerResolve); + """ + ) + self.assertEqual(recoveredClientID, expectedClientID) + + # Try not to pollute the profile list by getting rid of the one we just + # created. + self.marionette.quit() + self.marionette.instance.profile = originalProfile + self.marionette.start_session() + self.marionette.set_context("chrome") + self.marionette.execute_script( + """ + let newProfileName = arguments[0]; + let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + let profile = profileSvc.getProfileByName(newProfileName); + profile.remove(true); + profileSvc.flush(); + """, + script_args=[newProfileName], + ) + + # Cleanup the staging path that we moved + mozfile.remove(stagingPath) + + def add_test_cookie(self): + self.marionette.execute_async_script( + """ + let [outerResolve] = arguments; + (async () => { + // We'll just add a single cookie, and then make sure that it shows + // up on the other side. + Services.cookies.removeAll(); + Services.cookies.add( + ".example.com", + "/", + "first", + "one", + false, + false, + false, + Date.now() / 1000 + 1, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTP + ); + })().then(outerResolve); + """ + ) + + def verify_recovered_test_cookie(self): + cookiesLength = self.marionette.execute_async_script( + """ + let [outerResolve] = arguments; + (async () => { + let cookies = Services.cookies.getCookiesFromHost("example.com", {}); + return cookies.length; + })().then(outerResolve); + """ + ) + self.assertEqual(cookiesLength, 1) + + def add_test_login(self): + self.marionette.execute_async_script( + """ + let [outerResolve] = arguments; + (async () => { + // Let's start with adding a single password + Services.logins.removeAllLogins(); + + const nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + + const login1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" + ); + await Services.logins.addLoginAsync(login1); + })().then(outerResolve); + """ + ) + + def verify_recovered_test_login(self): + loginsLength = self.marionette.execute_async_script( + """ + let [outerResolve] = arguments; + (async () => { + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://example.com", + }); + return logins.length; + })().then(outerResolve); + """ + ) + self.assertEqual(loginsLength, 1) + + def add_test_certificate(self): + certPath = os.path.join(os.path.dirname(__file__), "http2-ca.pem") + self.marionette.execute_async_script( + """ + let [certPath, certDbKey, outerResolve] = arguments; + (async () => { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + + let certDb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + + if (certDb.findCertByDBKey(certDbKey)) { + throw new Error("Should not have this certificate yet!"); + } + + let certFile = await IOUtils.getFile(certPath); + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fstream.init(certFile, -1, 0, 0); + let data = NetUtil.readInputStreamToString(fstream, fstream.available()); + fstream.close(); + + let pem = data.replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/[\\r\\n]/g, ""); + let cert = certDb.addCertFromBase64(pem, "CTu,u,u"); + + if (cert.dbKey != certDbKey) { + throw new Error("The inserted certificate DB key is unexpected."); + } + })().then(outerResolve); + """, + script_args=[certPath, self._cert_db_key], + ) + + def verify_recovered_test_certificate(self): + certExists = self.marionette.execute_async_script( + """ + let [certDbKey, outerResolve] = arguments; + (async () => { + let certDb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + return certDb.findCertByDBKey(certDbKey) != null; + })().then(outerResolve); + """, + script_args=[self._cert_db_key], + ) + self.assertTrue(certExists) + + def add_test_saved_address(self): + self.marionette.execute_async_script( + """ + const { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + const TEST_ADDRESS_1 = { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\\\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+15195555555", + email: "user@example.com", + }; + await formAutofillStorage.initialize(); + formAutofillStorage.addresses.removeAll(); + await formAutofillStorage.addresses.add(TEST_ADDRESS_1); + })().then(outerResolve); + """ + ) + + def verify_recovered_saved_address(self): + addressesLength = self.marionette.execute_async_script( + """ + const { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + await formAutofillStorage.initialize(); + let addresses = await formAutofillStorage.addresses.getAll(); + return addresses.length; + })().then(outerResolve); + """ + ) + self.assertEqual(addressesLength, 1) + + def add_test_identity_credential(self): + self.marionette.execute_async_script( + """ + let [outerResolve] = arguments; + (async () => { + let service = Cc["@mozilla.org/browser/identity-credential-storage-service;1"] + .getService(Ci.nsIIdentityCredentialStorageService); + service.clear(); + + let testPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://test.com/"), + {} + ); + let idpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://idp-test.com/"), + {} + ); + + service.setState( + testPrincipal, + idpPrincipal, + "ID", + true, + true + ); + + })().then(outerResolve); + """ + ) + + def verify_recovered_identity_credential(self): + [registered, allowLogout] = self.marionette.execute_async_script( + """ + let [outerResolve] = arguments; + (async () => { + let service = Cc["@mozilla.org/browser/identity-credential-storage-service;1"] + .getService(Ci.nsIIdentityCredentialStorageService); + + let testPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://test.com/"), + {} + ); + let idpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://idp-test.com/"), + {} + ); + + let registered = {}; + let allowLogout = {}; + + service.getState( + testPrincipal, + idpPrincipal, + "ID", + registered, + allowLogout + ); + + return [registered.value, allowLogout.value]; + })().then(outerResolve); + """ + ) + self.assertTrue(registered) + self.assertTrue(allowLogout) + + def add_test_form_history(self): + self.marionette.execute_async_script( + """ + const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + await FormHistory.update({ + op: "add", + fieldname: "some-test-field", + value: "I was recovered!", + timesUsed: 1, + firstUsed: 0, + lastUsed: 0, + }); + + })().then(outerResolve); + """ + ) + + def verify_recovered_form_history(self): + formHistoryResultsLength = self.marionette.execute_async_script( + """ + const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + let results = await FormHistory.search( + ["guid"], + { fieldname: "some-test-field" } + ); + return results.length; + })().then(outerResolve); + """ + ) + self.assertEqual(formHistoryResultsLength, 1) + + def add_test_activity_stream_snippets_data(self): + self.marionette.execute_async_script( + """ + const { ActivityStreamStorage } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs", + ); + const SNIPPETS_TABLE_NAME = "snippets"; + + let [outerResolve] = arguments; + (async () => { + let storage = new ActivityStreamStorage({ + storeNames: [SNIPPETS_TABLE_NAME], + }); + let snippetsTable = await storage.getDbTable(SNIPPETS_TABLE_NAME); + await snippetsTable.set("backup-test", "some-test-value"); + })().then(outerResolve); + """ + ) + + def verify_recovered_activity_stream_snippets_data(self): + snippetsResult = self.marionette.execute_async_script( + """ + const { ActivityStreamStorage } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs", + ); + const SNIPPETS_TABLE_NAME = "snippets"; + + let [outerResolve] = arguments; + (async () => { + let storage = new ActivityStreamStorage({ + storeNames: [SNIPPETS_TABLE_NAME], + }); + let snippetsTable = await storage.getDbTable(SNIPPETS_TABLE_NAME); + return await snippetsTable.get("backup-test"); + })().then(outerResolve); + """ + ) + self.assertEqual(snippetsResult, "some-test-value") + + def add_test_protections_data(self): + self.marionette.execute_async_script( + """ + const TrackingDBService = Cc["@mozilla.org/tracking-db-service;1"] + .getService(Ci.nsITrackingDBService); + + let [outerResolve] = arguments; + (async () => { + let entry = { + "https://test.com": [ + [Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT, true, 1], + ], + }; + await TrackingDBService.clearAll(); + await TrackingDBService.saveEvents(JSON.stringify(entry)); + })().then(outerResolve); + """ + ) + + def verify_recovered_protections_data(self): + eventsSum = self.marionette.execute_async_script( + """ + const TrackingDBService = Cc["@mozilla.org/tracking-db-service;1"] + .getService(Ci.nsITrackingDBService); + + let [outerResolve] = arguments; + (async () => { + return TrackingDBService.sumAllEvents(); + })().then(outerResolve); + """ + ) + self.assertEqual(eventsSum, 1) + + def add_test_bookmarks(self): + self.marionette.execute_async_script( + """ + const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Some test page", + url: Services.io.newURI("https://www.backup.test/"), + }); + })().then(outerResolve); + """ + ) + + def verify_recovered_bookmarks(self): + bookmarkExists = self.marionette.execute_async_script( + """ + const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + let url = Services.io.newURI("https://www.backup.test/"); + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + return bookmark != null; + })().then(outerResolve); + """ + ) + self.assertTrue(bookmarkExists) + + def add_test_history(self): + self.marionette.execute_async_script( + """ + const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + await PlacesUtils.history.clear(); + + let entry = { + url: "http://my-restored-history.com", + visits: [{ transition: PlacesUtils.history.TRANSITION_LINK }], + }; + + await PlacesUtils.history.insertMany([entry]); + })().then(outerResolve); + """ + ) + + def verify_recovered_history(self): + historyExists = self.marionette.execute_async_script( + """ + const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" + ); + + let [outerResolve] = arguments; + (async () => { + let entry = await PlacesUtils.history.fetch("http://my-restored-history.com"); + return entry != null; + })().then(outerResolve); + """ + ) + self.assertTrue(historyExists) + + def add_test_preferences(self): + self.marionette.execute_script( + """ + Services.prefs.setBoolPref("test-pref-for-backup", true) + """ + ) + + def verify_recovered_preferences(self): + prefExists = self.marionette.execute_script( + """ + return Services.prefs.getBoolPref("test-pref-for-backup", false); + """ + ) + self.assertTrue(prefExists) + + def add_test_permissions(self): + self.marionette.execute_script( + """ + let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://test-permission-site.com" + ); + Services.perms.addFromPrincipal( + principal, + "desktop-notification", + Services.perms.ALLOW_ACTION + ); + """ + ) + + def verify_recovered_permissions(self): + permissionExists = self.marionette.execute_script( + """ + let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://test-permission-site.com" + ); + let perms = Services.perms.getAllForPrincipal(principal); + if (perms.length != 1) { + throw new Error("Got an unexpected number of permissions"); + } + return perms[0].type == "desktop-notification" + """ + ) + self.assertTrue(permissionExists) diff --git a/browser/components/backup/tests/xpcshell/data/test_xulstore.json b/browser/components/backup/tests/xpcshell/data/test_xulstore.json index 0d0890ab16..e4ae6f1f66 100644 --- a/browser/components/backup/tests/xpcshell/data/test_xulstore.json +++ b/browser/components/backup/tests/xpcshell/data/test_xulstore.json @@ -9,7 +9,6 @@ "sizemode": "normal" }, "sidebar-box": { - "sidebarcommand": "viewBookmarksSidebar", "width": "323", "style": "width: 323px;" }, diff --git a/browser/components/backup/tests/xpcshell/head.js b/browser/components/backup/tests/xpcshell/head.js index 2402870a13..e5ed32fb63 100644 --- a/browser/components/backup/tests/xpcshell/head.js +++ b/browser/components/backup/tests/xpcshell/head.js @@ -50,6 +50,9 @@ class FakeBackupResource2 extends BackupResource { static get requiresEncryption() { return true; } + static get priority() { + return 1; + } } /** @@ -62,6 +65,9 @@ class FakeBackupResource3 extends BackupResource { static get requiresEncryption() { return false; } + static get priority() { + return 2; + } } /** diff --git a/browser/components/backup/tests/xpcshell/test_AddonsBackupResource.js b/browser/components/backup/tests/xpcshell/test_AddonsBackupResource.js new file mode 100644 index 0000000000..d1c47ecdb0 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_AddonsBackupResource.js @@ -0,0 +1,416 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonsBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/AddonsBackupResource.sys.mjs" +); + +/** + * Tests that we can measure the size of all the addons & extensions data. + */ +add_task(async function test_measure() { + 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 + * and moz-extension IndexedDB databases not existing. + */ +add_task(async function test_measure_missing_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 permissions data" + ); + + let extensionsStorageSizeMeasurement = + Glean.browserBackup.extensionsStorageSize.testGetValue(); + Assert.equal( + extensionsStorageSizeMeasurement, + null, + "Should NOT have collected a measurement for the missing storage data" + ); +}); + +/** + * 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 addonsBackupResource = new AddonsBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "AddonsBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "AddonsBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: "extensions.json" }, + { path: "extension-settings.json" }, + { path: "extension-preferences.json" }, + { path: "addonStartup.json.lz4" }, + { + path: [ + "browser-extension-data", + "{11aa1234-f111-1234-abcd-a9b8c7654d32}", + ], + }, + { path: ["extension-store-permissions", "data.safe.bin"] }, + { path: ["extensions", "{11aa1234-f111-1234-abcd-a9b8c7654d32}.xpi"] }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + const junkFiles = [{ path: ["extensions", "junk"] }]; + await createTestFiles(sourcePath, junkFiles); + + // Create a fake storage-sync-v2 database file. We don't expect this to + // be copied to the staging directory in this test due to our stubbing + // of the backup method, so we don't include it in `simpleCopyFiles`. + await createTestFiles(sourcePath, [{ path: "storage-sync-v2.sqlite" }]); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + let manifestEntry = await addonsBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "AddonsBackupResource.backup should return null as its ManifestEntry" + ); + + await assertFilesExist(stagingPath, simpleCopyFiles); + + let junkFile = PathUtils.join(stagingPath, "extensions", "junk"); + Assert.equal( + await IOUtils.exists(junkFile), + false, + `${junkFile} should not exist in the staging folder` + ); + + // Make sure storage-sync-v2 database is backed up. + Assert.ok( + fakeConnection.backup.calledOnce, + "Called backup the expected number of times for all connections" + ); + Assert.ok( + fakeConnection.backup.calledWith( + PathUtils.join(stagingPath, "storage-sync-v2.sqlite") + ), + "Called backup on the storage-sync-v2 Sqlite connection" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let addonsBackupResource = new AddonsBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "addonsBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "addonsBackupResource-test-profile" + ); + + const files = [ + { path: "extensions.json" }, + { path: "extension-settings.json" }, + { path: "extension-preferences.json" }, + { path: "addonStartup.json.lz4" }, + { path: "storage-sync-v2.sqlite" }, + { path: ["browser-extension-data", "addon@darkreader.org.xpi", "data"] }, + { path: ["extensions", "addon@darkreader.org.xpi"] }, + { path: ["extension-store-permissions", "data.safe.bin"] }, + ]; + await createTestFiles(recoveryPath, files); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await addonsBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "AddonsBackupResource.recover should return null as its post " + + "recovery entry" + ); + + await assertFilesExist(destProfilePath, files); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_BackupResource.js b/browser/components/backup/tests/xpcshell/test_BackupResource.js index 6623f4cd77..42cda918f9 100644 --- a/browser/components/backup/tests/xpcshell/test_BackupResource.js +++ b/browser/components/backup/tests/xpcshell/test_BackupResource.js @@ -31,7 +31,8 @@ add_task(async function test_getFileSize() { }); /** - * Tests that BackupService.getDirectorySize 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"); @@ -75,3 +76,175 @@ add_task(async function test_bytesToFuzzyKilobytes() { Assert.equal(smallSize, 1, "Sizes under 10 kilobytes return 1 kilobyte"); }); + +/** + * Tests that BackupResource.copySqliteDatabases will call `backup` on a new + * read-only connection on each database file. + */ +add_task(async function test_copySqliteDatabases() { + let sandbox = sinon.createSandbox(); + const SQLITE_PAGES_PER_STEP_PREF = "browser.backup.sqlite.pages_per_step"; + const SQLITE_STEP_DELAY_MS_PREF = "browser.backup.sqlite.step_delay_ms"; + const DEFAULT_SQLITE_PAGES_PER_STEP = Services.prefs.getIntPref( + SQLITE_PAGES_PER_STEP_PREF + ); + const DEFAULT_SQLITE_STEP_DELAY_MS = Services.prefs.getIntPref( + SQLITE_STEP_DELAY_MS_PREF + ); + + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "BackupResource-source-test" + ); + let destPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "BackupResource-dest-test" + ); + let pretendDatabases = ["places.sqlite", "favicons.sqlite"]; + await createTestFiles( + sourcePath, + pretendDatabases.map(f => ({ path: f })) + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await BackupResource.copySqliteDatabases( + sourcePath, + destPath, + pretendDatabases + ); + + Assert.ok( + Sqlite.openConnection.calledTwice, + "Sqlite.openConnection called twice" + ); + Assert.ok( + Sqlite.openConnection.firstCall.calledWith({ + path: PathUtils.join(sourcePath, "places.sqlite"), + readOnly: true, + }), + "openConnection called with places.sqlite as read-only" + ); + Assert.ok( + Sqlite.openConnection.secondCall.calledWith({ + path: PathUtils.join(sourcePath, "favicons.sqlite"), + readOnly: true, + }), + "openConnection called with favicons.sqlite as read-only" + ); + + Assert.ok( + fakeConnection.backup.calledTwice, + "backup on an Sqlite connection called twice" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(destPath, "places.sqlite"), + DEFAULT_SQLITE_PAGES_PER_STEP, + DEFAULT_SQLITE_STEP_DELAY_MS + ), + "backup called with places.sqlite to the destination path with the right " + + "pages per step and step delay" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(destPath, "favicons.sqlite"), + DEFAULT_SQLITE_PAGES_PER_STEP, + DEFAULT_SQLITE_STEP_DELAY_MS + ), + "backup called with favicons.sqlite to the destination path with the " + + "right pages per step and step delay" + ); + + Assert.ok( + fakeConnection.close.calledTwice, + "close on an Sqlite connection called twice" + ); + + // Now check that we can override the default pages per step and step delay. + fakeConnection.backup.resetHistory(); + const NEW_SQLITE_PAGES_PER_STEP = 10; + const NEW_SQLITE_STEP_DELAY_MS = 500; + Services.prefs.setIntPref( + SQLITE_PAGES_PER_STEP_PREF, + NEW_SQLITE_PAGES_PER_STEP + ); + Services.prefs.setIntPref( + SQLITE_STEP_DELAY_MS_PREF, + NEW_SQLITE_STEP_DELAY_MS + ); + await BackupResource.copySqliteDatabases( + sourcePath, + destPath, + pretendDatabases + ); + Assert.ok( + fakeConnection.backup.calledTwice, + "backup on an Sqlite connection called twice" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(destPath, "places.sqlite"), + NEW_SQLITE_PAGES_PER_STEP, + NEW_SQLITE_STEP_DELAY_MS + ), + "backup called with places.sqlite to the destination path with the right " + + "pages per step and step delay" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(destPath, "favicons.sqlite"), + NEW_SQLITE_PAGES_PER_STEP, + NEW_SQLITE_STEP_DELAY_MS + ), + "backup called with favicons.sqlite to the destination path with the " + + "right pages per step and step delay" + ); + + await maybeRemovePath(sourcePath); + await maybeRemovePath(destPath); + sandbox.restore(); +}); + +/** + * Tests that BackupResource.copyFiles will copy files from one directory to + * another. + */ +add_task(async function test_copyFiles() { + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "BackupResource-source-test" + ); + let destPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "BackupResource-dest-test" + ); + + const testFiles = [ + { path: "file1.txt" }, + { path: ["some", "nested", "file", "file2.txt"] }, + { path: "file3.txt" }, + ]; + + await createTestFiles(sourcePath, testFiles); + + await BackupResource.copyFiles(sourcePath, destPath, [ + "file1.txt", + "some", + "file3.txt", + "does-not-exist.txt", + ]); + + await assertFilesExist(destPath, testFiles); + Assert.ok( + !(await IOUtils.exists(PathUtils.join(destPath, "does-not-exist.txt"))), + "does-not-exist.txt wasn't somehow written to." + ); + + await maybeRemovePath(sourcePath); + await maybeRemovePath(destPath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_BackupService.js b/browser/components/backup/tests/xpcshell/test_BackupService.js new file mode 100644 index 0000000000..33fb9fbb99 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_BackupService.js @@ -0,0 +1,451 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { JsonSchemaValidator } = ChromeUtils.importESModule( + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); +const { ClientID } = ChromeUtils.importESModule( + "resource://gre/modules/ClientID.sys.mjs" +); + +add_setup(function () { + // Much of this setup is copied from toolkit/profile/xpcshell/head.js. It is + // needed in order to put the xpcshell test environment into the state where + // it thinks its profile is the one pointed at by + // nsIToolkitProfileService.currentProfile. + let gProfD = do_get_profile(); + let gDataHome = gProfD.clone(); + gDataHome.append("data"); + gDataHome.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + let gDataHomeLocal = gProfD.clone(); + gDataHomeLocal.append("local"); + gDataHomeLocal.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + + let xreDirProvider = Cc["@mozilla.org/xre/directory-provider;1"].getService( + Ci.nsIXREDirProvider + ); + xreDirProvider.setUserDataDirectory(gDataHome, false); + xreDirProvider.setUserDataDirectory(gDataHomeLocal, true); + + let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + + let createdProfile = {}; + let didCreate = profileSvc.selectStartupProfile( + ["xpcshell"], + false, + AppConstants.UPDATE_CHANNEL, + "", + {}, + {}, + createdProfile + ); + Assert.ok(didCreate, "Created a testing profile and set it to current."); + Assert.equal( + profileSvc.currentProfile, + createdProfile.value, + "Profile set to current" + ); +}); + +/** + * A utility function for testing BackupService.createBackup. This helper + * function: + * + * 1. Ensures that `backup` will be called on BackupResources with the service + * 2. Ensures that a backup-manifest.json will be written and contain the + * ManifestEntry data returned by each BackupResource. + * 3. Ensures that a `staging` folder will be written to and renamed properly + * once the backup creation is complete. + * + * Once this is done, a task function can be run. The task function is passed + * the parsed backup-manifest.json object as its only argument. + * + * @param {object} sandbox + * The Sinon sandbox to be used stubs and mocks. The test using this helper + * is responsible for creating and resetting this sandbox. + * @param {Function} taskFn + * A function that is run once all default checks are done on the manifest + * and staging folder. After this function returns, the staging folder will + * be cleaned up. + * @returns {Promise<undefined>} + */ +async function testCreateBackupHelper(sandbox, taskFn) { + const EXPECTED_CLIENT_ID = await ClientID.getClientID(); + + let fake1ManifestEntry = { fake1: "hello from 1" }; + sandbox + .stub(FakeBackupResource1.prototype, "backup") + .resolves(fake1ManifestEntry); + + sandbox + .stub(FakeBackupResource2.prototype, "backup") + .rejects(new Error("Some failure to backup")); + + let fake3ManifestEntry = { fake3: "hello from 3" }; + sandbox + .stub(FakeBackupResource3.prototype, "backup") + .resolves(fake3ManifestEntry); + + let bs = new BackupService({ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + }); + + let fakeProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "createBackupTest" + ); + + await bs.createBackup({ profilePath: fakeProfilePath }); + + // We expect the staging folder to exist then be renamed under the fakeProfilePath. + // We should also find a folder for each fake BackupResource. + let backupsFolderPath = PathUtils.join(fakeProfilePath, "backups"); + let stagingPath = PathUtils.join(backupsFolderPath, "staging"); + + // For now, we expect a single backup only to be saved. + let backups = await IOUtils.getChildren(backupsFolderPath); + Assert.equal( + backups.length, + 1, + "There should only be 1 backup in the backups folder" + ); + + let renamedFilename = await PathUtils.filename(backups[0]); + let expectedFormatRegex = /^\d{4}(-\d{2}){2}T(\d{2}-){2}\d{2}Z$/; + Assert.ok( + renamedFilename.match(expectedFormatRegex), + "Renamed staging folder should have format YYYY-MM-DDTHH-mm-ssZ" + ); + + let stagingPathRenamed = PathUtils.join(backupsFolderPath, renamedFilename); + + for (let backupResourceClass of [ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + ]) { + let expectedResourceFolderBeforeRename = PathUtils.join( + stagingPath, + backupResourceClass.key + ); + let expectedResourceFolderAfterRename = PathUtils.join( + stagingPathRenamed, + backupResourceClass.key + ); + + Assert.ok( + await IOUtils.exists(expectedResourceFolderAfterRename), + `BackupResource folder exists for ${backupResourceClass.key} after rename` + ); + Assert.ok( + backupResourceClass.prototype.backup.calledOnce, + `Backup was called for ${backupResourceClass.key}` + ); + Assert.ok( + backupResourceClass.prototype.backup.calledWith( + expectedResourceFolderBeforeRename, + fakeProfilePath + ), + `Backup was called in the staging folder for ${backupResourceClass.key} before rename` + ); + } + + // Check that resources were called from highest to lowest backup priority. + sinon.assert.callOrder( + FakeBackupResource3.prototype.backup, + FakeBackupResource2.prototype.backup, + FakeBackupResource1.prototype.backup + ); + + let manifestPath = PathUtils.join( + stagingPathRenamed, + BackupService.MANIFEST_FILE_NAME + ); + + Assert.ok(await IOUtils.exists(manifestPath), "Manifest file exists"); + let manifest = await IOUtils.readJSON(manifestPath); + + let schema = await BackupService.MANIFEST_SCHEMA; + let validationResult = JsonSchemaValidator.validate(manifest, schema); + Assert.ok(validationResult.valid, "Schema matches manifest"); + Assert.deepEqual( + Object.keys(manifest.resources).sort(), + ["fake1", "fake3"], + "Manifest contains all expected BackupResource keys" + ); + Assert.deepEqual( + manifest.resources.fake1, + fake1ManifestEntry, + "Manifest contains the expected entry for FakeBackupResource1" + ); + Assert.deepEqual( + manifest.resources.fake3, + fake3ManifestEntry, + "Manifest contains the expected entry for FakeBackupResource3" + ); + Assert.equal( + manifest.meta.legacyClientID, + EXPECTED_CLIENT_ID, + "The client ID was stored properly." + ); + + taskFn(manifest); + + // 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 }); +} + +/** + * 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. Tests in the signed-out state. + */ +add_task(async function test_createBackup_signed_out() { + let sandbox = sinon.createSandbox(); + + sandbox + .stub(UIState, "get") + .returns({ status: UIState.STATUS_NOT_CONFIGURED }); + await testCreateBackupHelper(sandbox, manifest => { + Assert.equal( + manifest.meta.accountID, + undefined, + "Account ID should be undefined." + ); + Assert.equal( + manifest.meta.accountEmail, + undefined, + "Account email should be undefined." + ); + }); + + sandbox.restore(); +}); + +/** + * 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. Tests in the signed-in state. + */ +add_task(async function test_createBackup_signed_in() { + let sandbox = sinon.createSandbox(); + + const TEST_UID = "ThisIsMyTestUID"; + const TEST_EMAIL = "foxy@mozilla.org"; + + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + uid: TEST_UID, + email: TEST_EMAIL, + }); + + await testCreateBackupHelper(sandbox, manifest => { + Assert.equal( + manifest.meta.accountID, + TEST_UID, + "Account ID should be set properly." + ); + Assert.equal( + manifest.meta.accountEmail, + TEST_EMAIL, + "Account email should be set properly." + ); + }); + + sandbox.restore(); +}); + +/** + * Creates a directory that looks a lot like a decompressed backup archive, + * and then tests that BackupService.recoverFromBackup can create a new profile + * and recover into it. + */ +add_task(async function test_recoverFromBackup() { + let sandbox = sinon.createSandbox(); + let fakeEntryMap = new Map(); + let backupResourceClasses = [ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + ]; + + let i = 1; + for (let backupResourceClass of backupResourceClasses) { + let fakeManifestEntry = { [`fake${i}`]: `hello from backup - ${i}` }; + sandbox + .stub(backupResourceClass.prototype, "backup") + .resolves(fakeManifestEntry); + + let fakePostRecoveryEntry = { [`fake${i}`]: `hello from recover - ${i}` }; + sandbox + .stub(backupResourceClass.prototype, "recover") + .resolves(fakePostRecoveryEntry); + + fakeEntryMap.set(backupResourceClass, { + manifestEntry: fakeManifestEntry, + postRecoveryEntry: fakePostRecoveryEntry, + }); + + ++i; + } + + let bs = new BackupService({ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + }); + + let oldProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "recoverFromBackupTest" + ); + let newProfileRootPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "recoverFromBackupTest-newProfileRoot" + ); + + let { stagingPath } = await bs.createBackup({ profilePath: oldProfilePath }); + + let testTelemetryStateObject = { + clientID: "ed209123-04a1-04a1-04a1-c0ffeec0ffee", + }; + await IOUtils.writeJSON( + PathUtils.join(PathUtils.profileDir, "datareporting", "state.json"), + testTelemetryStateObject + ); + + let profile = await bs.recoverFromBackup( + stagingPath, + false /* shouldLaunch */, + newProfileRootPath + ); + Assert.ok(profile, "An nsIToolkitProfile was created."); + let newProfilePath = profile.rootDir.path; + + let postRecoveryFilePath = PathUtils.join( + newProfilePath, + "post-recovery.json" + ); + let postRecovery = await IOUtils.readJSON(postRecoveryFilePath); + + for (let backupResourceClass of backupResourceClasses) { + let expectedResourceFolder = PathUtils.join( + stagingPath, + backupResourceClass.key + ); + + let { manifestEntry, postRecoveryEntry } = + fakeEntryMap.get(backupResourceClass); + + Assert.ok( + backupResourceClass.prototype.recover.calledOnce, + `Recover was called for ${backupResourceClass.key}` + ); + Assert.ok( + backupResourceClass.prototype.recover.calledWith( + manifestEntry, + expectedResourceFolder, + newProfilePath + ), + `Recover was passed the right arguments for ${backupResourceClass.key}` + ); + Assert.deepEqual( + postRecoveryEntry, + postRecovery[backupResourceClass.key], + "The post recovery data is as expected" + ); + } + + let newProfileTelemetryStateObject = await IOUtils.readJSON( + PathUtils.join(newProfileRootPath, "datareporting", "state.json") + ); + Assert.deepEqual( + testTelemetryStateObject, + newProfileTelemetryStateObject, + "Recovered profile inherited telemetry state from the profile that " + + "initiated recovery" + ); + + await IOUtils.remove(oldProfilePath, { recursive: true }); + await IOUtils.remove(newProfileRootPath, { recursive: true }); + sandbox.restore(); +}); + +/** + * Tests that if there's a post-recovery.json file in the profile directory + * when checkForPostRecovery() is called, that it is processed, and the + * postRecovery methods on the associated BackupResources are called with the + * entry values from the file. + */ +add_task(async function test_checkForPostRecovery() { + let sandbox = sinon.createSandbox(); + + let testProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "checkForPostRecoveryTest" + ); + let fakePostRecoveryObject = { + [FakeBackupResource1.key]: "test 1", + [FakeBackupResource3.key]: "test 3", + }; + await IOUtils.writeJSON( + PathUtils.join(testProfilePath, BackupService.POST_RECOVERY_FILE_NAME), + fakePostRecoveryObject + ); + + sandbox.stub(FakeBackupResource1.prototype, "postRecovery").resolves(); + sandbox.stub(FakeBackupResource2.prototype, "postRecovery").resolves(); + sandbox.stub(FakeBackupResource3.prototype, "postRecovery").resolves(); + + let bs = new BackupService({ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + }); + + await bs.checkForPostRecovery(testProfilePath); + await bs.postRecoveryComplete; + + Assert.ok( + FakeBackupResource1.prototype.postRecovery.calledOnce, + "FakeBackupResource1.postRecovery was called once" + ); + Assert.ok( + FakeBackupResource2.prototype.postRecovery.notCalled, + "FakeBackupResource2.postRecovery was not called" + ); + Assert.ok( + FakeBackupResource3.prototype.postRecovery.calledOnce, + "FakeBackupResource3.postRecovery was called once" + ); + Assert.ok( + FakeBackupResource1.prototype.postRecovery.calledWith( + fakePostRecoveryObject[FakeBackupResource1.key] + ), + "FakeBackupResource1.postRecovery was called with the expected argument" + ); + Assert.ok( + FakeBackupResource3.prototype.postRecovery.calledWith( + fakePostRecoveryObject[FakeBackupResource3.key] + ), + "FakeBackupResource3.postRecovery was called with the expected argument" + ); + + await IOUtils.remove(testProfilePath, { recursive: true }); + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_BackupService_takeMeasurements.js b/browser/components/backup/tests/xpcshell/test_BackupService_takeMeasurements.js new file mode 100644 index 0000000000..c73482dfe6 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_BackupService_takeMeasurements.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(() => { + // 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() { + let bs = new BackupService(); + await bs.takeMeasurements(); + let measurement = Glean.browserBackup.profDDiskSpace.testGetValue(); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "browser.backup.prof_d_disk_space", + measurement + ); + + Assert.greater( + measurement, + 0, + "Should have collected a measurement for the profile directory storage " + + "device" + ); +}); diff --git a/browser/components/backup/tests/xpcshell/test_CookiesBackupResource.js b/browser/components/backup/tests/xpcshell/test_CookiesBackupResource.js new file mode 100644 index 0000000000..1690580437 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_CookiesBackupResource.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CookiesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/CookiesBackupResource.sys.mjs" +); + +/** + * Tests that we can measure the Cookies db in a profile directory. + */ +add_task(async function test_measure() { + 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); +}); + +/** + * 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 cookiesBackupResource = new CookiesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CookiesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CookiesBackupResource-staging-test" + ); + + // Make sure this file exists in the source directory, otherwise + // BackupResource will skip attempting to back it up. + await createTestFiles(sourcePath, [{ path: "cookies.sqlite" }]); + + // 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); + + let manifestEntry = await cookiesBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "CookiesBackupResource.backup should return null as its ManifestEntry" + ); + + // 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.calledWith( + PathUtils.join(stagingPath, "cookies.sqlite") + ), + "Called backup on the cookies.sqlite Sqlite connection" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let cookiesBackupResource = new CookiesBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CookiesBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CookiesBackupResource-test-profile" + ); + + const simpleCopyFiles = [{ path: "cookies.sqlite" }]; + await createTestFiles(recoveryPath, simpleCopyFiles); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await cookiesBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "CookiesBackupResource.recover should return null as its post " + + "recovery entry" + ); + + await assertFilesExist(destProfilePath, simpleCopyFiles); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_CredentialsAndSecurityBackupResource.js b/browser/components/backup/tests/xpcshell/test_CredentialsAndSecurityBackupResource.js new file mode 100644 index 0000000000..f53fec8d3f --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_CredentialsAndSecurityBackupResource.js @@ -0,0 +1,215 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CredentialsAndSecurityBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/CredentialsAndSecurityBackupResource.sys.mjs" +); + +/** + * Tests that we can measure credentials related files in the profile directory. + */ +add_task(async function test_measure() { + 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); +}); + +/** + * 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 credentialsAndSecurityBackupResource = + new CredentialsAndSecurityBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CredentialsAndSecurityBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CredentialsAndSecurityBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: "logins.json", sizeInKB: 1 }, + { path: "logins-backup.json", sizeInKB: 1 }, + { path: "autofill-profiles.json", sizeInKB: 1 }, + { path: "signedInUser.json", sizeInKB: 5 }, + { path: "pkcs11.txt", sizeInKB: 1 }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + // Create our fake database files. We don't expect these to be copied to the + // staging directory in this test due to our stubbing of the backup method, so + // we don't include it in `simpleCopyFiles`. + await createTestFiles(sourcePath, [ + { path: "cert9.db" }, + { path: "key4.db" }, + { path: "credentialstate.sqlite" }, + ]); + + // 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); + + let manifestEntry = await credentialsAndSecurityBackupResource.backup( + stagingPath, + sourcePath + ); + + Assert.equal( + manifestEntry, + null, + "CredentialsAndSecurityBackupResource.backup should return null as its ManifestEntry" + ); + + 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.calledThrice, + "Called backup the expected number of times for all connections" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "cert9.db") + ), + "Called backup on cert9.db connection first" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(stagingPath, "key4.db") + ), + "Called backup on key4.db connection second" + ); + Assert.ok( + fakeConnection.backup.thirdCall.calledWith( + PathUtils.join(stagingPath, "credentialstate.sqlite") + ), + "Called backup on credentialstate.sqlite connection third" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let credentialsAndSecurityBackupResource = + new CredentialsAndSecurityBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CredentialsAndSecurityBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CredentialsAndSecurityBackupResource-test-profile" + ); + + const files = [ + { path: "logins.json" }, + { path: "logins-backup.json" }, + { path: "autofill-profiles.json" }, + { path: "credentialstate.sqlite" }, + { path: "signedInUser.json" }, + { path: "cert9.db" }, + { path: "key4.db" }, + { path: "pkcs11.txt" }, + ]; + await createTestFiles(recoveryPath, files); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await credentialsAndSecurityBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "CredentialsAndSecurityBackupResource.recover should return null as its post " + + "recovery entry" + ); + + await assertFilesExist(destProfilePath, files); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_FormHistoryBackupResource.js b/browser/components/backup/tests/xpcshell/test_FormHistoryBackupResource.js new file mode 100644 index 0000000000..93434daa9c --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_FormHistoryBackupResource.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FormHistoryBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/FormHistoryBackupResource.sys.mjs" +); + +/** + * Tests that we can measure the Form History db in a profile directory. + */ +add_task(async function test_measure() { + 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); +}); + +/** + * 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 formHistoryBackupResource = new FormHistoryBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "FormHistoryBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "FormHistoryBackupResource-staging-test" + ); + + // Make sure this file exists in the source directory, otherwise + // BackupResource will skip attempting to back it up. + await createTestFiles(sourcePath, [{ path: "formhistory.sqlite" }]); + + // 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); + + let manifestEntry = await formHistoryBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "FormHistoryBackupResource.backup should return null as its ManifestEntry" + ); + + // 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.calledWith( + PathUtils.join(stagingPath, "formhistory.sqlite") + ), + "Called backup on the formhistory.sqlite Sqlite connection" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let formHistoryBackupResource = new FormHistoryBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "FormHistoryBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "FormHistoryBackupResource-test-profile" + ); + + const simpleCopyFiles = [{ path: "formhistory.sqlite" }]; + await createTestFiles(recoveryPath, simpleCopyFiles); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await formHistoryBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "FormHistoryBackupResource.recover should return null as its post " + + "recovery entry" + ); + + await assertFilesExist(destProfilePath, simpleCopyFiles); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js b/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js index e57dd50cd3..ab63b65332 100644 --- a/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js +++ b/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js @@ -7,20 +7,27 @@ const { MiscDataBackupResource } = ChromeUtils.importESModule( "resource:///modules/backup/MiscDataBackupResource.sys.mjs" ); +const { ActivityStreamStorage } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs" +); + +const { ProfileAge } = ChromeUtils.importESModule( + "resource://gre/modules/ProfileAge.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 EXPECTED_MISC_KILOBYTES_SIZE = 231; 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 }, @@ -69,12 +76,16 @@ add_task(async function test_backup() { ); const simpleCopyFiles = [ - { path: "times.json" }, { path: "enumerate_devices.txt" }, { path: "SiteSecurityServiceState.bin" }, ]; await createTestFiles(sourcePath, simpleCopyFiles); + // Create our fake database files. We don't expect this to be copied to the + // staging directory in this test due to our stubbing of the backup method, so + // we don't include it in `simpleCopyFiles`. + await createTestFiles(sourcePath, [{ path: "protections.sqlite" }]); + // 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 @@ -85,7 +96,27 @@ add_task(async function test_backup() { }; sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); - await miscDataBackupResource.backup(stagingPath, sourcePath); + let snippetsTableStub = { + getAllKeys: sandbox.stub().resolves(["key1", "key2"]), + get: sandbox.stub().callsFake(key => { + return { key: `value for ${key}` }; + }), + }; + + sandbox + .stub(ActivityStreamStorage.prototype, "getDbTable") + .withArgs("snippets") + .resolves(snippetsTableStub); + + let manifestEntry = await miscDataBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "MiscDataBackupResource.backup should return null as its ManifestEntry" + ); await assertFilesExist(stagingPath, simpleCopyFiles); @@ -102,12 +133,170 @@ add_task(async function test_backup() { "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. + // Bug 1890585 - we don't currently have the generalized ability to copy the + // chrome-privileged IndexedDB databases under storage/permanent/chrome, but + // we do support copying individual IndexedDB databases by manually exporting + // and re-importing their contents. + let snippetsBackupPath = PathUtils.join( + stagingPath, + "activity-stream-snippets.json" + ); + Assert.ok( + await IOUtils.exists(snippetsBackupPath), + "The activity-stream-snippets.json file should exist" + ); + let snippetsBackupContents = await IOUtils.readJSON(snippetsBackupPath); + Assert.deepEqual( + snippetsBackupContents, + { + key1: { key: "value for key1" }, + key2: { key: "value for key2" }, + }, + "The contents of the activity-stream-snippets.json file should be as expected" + ); await maybeRemovePath(stagingPath); await maybeRemovePath(sourcePath); sandbox.restore(); }); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let miscBackupResource = new MiscDataBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-test-profile" + ); + + // Write a dummy times.json into the xpcshell test profile directory. We + // expect it to be copied into the destination profile. + let originalProfileAge = await ProfileAge(PathUtils.profileDir); + await originalProfileAge.computeAndPersistCreated(); + Assert.ok( + await IOUtils.exists(PathUtils.join(PathUtils.profileDir, "times.json")) + ); + + const simpleCopyFiles = [ + { path: "enumerate_devices.txt" }, + { path: "protections.sqlite" }, + { path: "SiteSecurityServiceState.bin" }, + ]; + await createTestFiles(recoveryPath, simpleCopyFiles); + + const SNIPPETS_BACKUP_FILE = "activity-stream-snippets.json"; + + // We'll also separately create the activity-stream-snippets.json file, which + // is not expected to be copied into the profile directory, but is expected + // to exist in the recovery path. + await createTestFiles(recoveryPath, [{ path: SNIPPETS_BACKUP_FILE }]); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await miscBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.deepEqual( + postRecoveryEntry, + { + snippetsBackupFile: PathUtils.join(recoveryPath, SNIPPETS_BACKUP_FILE), + }, + "MiscDataBackupResource.recover should return the snippets backup data " + + "path as its post recovery entry" + ); + + await assertFilesExist(destProfilePath, simpleCopyFiles); + + // The activity-stream-snippets.json path should _not_ have been written to + // the profile path. + Assert.ok( + !(await IOUtils.exists( + PathUtils.join(destProfilePath, SNIPPETS_BACKUP_FILE) + )), + "Snippets backup data should not have gone into the profile directory" + ); + + // The times.json file should have been copied over and a backup recovery + // time written into it. + Assert.ok( + await IOUtils.exists(PathUtils.join(destProfilePath, "times.json")) + ); + let copiedProfileAge = await ProfileAge(destProfilePath); + Assert.equal( + await originalProfileAge.created, + await copiedProfileAge.created, + "Created timestamp should match." + ); + Assert.equal( + await originalProfileAge.firstUse, + await copiedProfileAge.firstUse, + "First use timestamp should match." + ); + Assert.ok( + await copiedProfileAge.recoveredFromBackup, + "Backup recovery timestamp should have been set." + ); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); + +/** + * Test that the postRecovery method correctly writes the snippets backup data + * into the snippets IndexedDB table. + */ +add_task(async function test_postRecovery() { + let sandbox = sinon.createSandbox(); + + let fakeProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-test-profile" + ); + let fakeSnippetsData = { + key1: "value1", + key2: "value2", + }; + const SNIPPEST_BACKUP_FILE = PathUtils.join( + fakeProfilePath, + "activity-stream-snippets.json" + ); + + await IOUtils.writeJSON(SNIPPEST_BACKUP_FILE, fakeSnippetsData); + + let snippetsTableStub = { + set: sandbox.stub(), + }; + + sandbox + .stub(ActivityStreamStorage.prototype, "getDbTable") + .withArgs("snippets") + .resolves(snippetsTableStub); + + let miscBackupResource = new MiscDataBackupResource(); + await miscBackupResource.postRecovery({ + snippetsBackupFile: SNIPPEST_BACKUP_FILE, + }); + + Assert.ok( + snippetsTableStub.set.calledTwice, + "The snippets table's set method was called twice" + ); + Assert.ok( + snippetsTableStub.set.firstCall.calledWith("key1", "value1"), + "The snippets table's set method was called with the first key-value pair" + ); + Assert.ok( + snippetsTableStub.set.secondCall.calledWith("key2", "value2"), + "The snippets table's set method was called with the second key-value pair" + ); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js b/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js index de97281372..7248a5c614 100644 --- a/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js +++ b/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js @@ -3,6 +3,9 @@ https://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; +const { BookmarkJSONUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkJSONUtils.sys.mjs" +); const { PlacesBackupResource } = ChromeUtils.importESModule( "resource:///modules/backup/PlacesBackupResource.sys.mjs" ); @@ -93,13 +96,28 @@ add_task(async function test_backup() { "PlacesBackupResource-staging-test" ); + // Make sure these files exist in the source directory, otherwise + // BackupResource will skip attempting to back them up. + await createTestFiles(sourcePath, [ + { path: "places.sqlite" }, + { path: "favicons.sqlite" }, + ]); + let fakeConnection = { backup: sandbox.stub().resolves(true), close: sandbox.stub().resolves(true), }; sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); - await placesBackupResource.backup(stagingPath, sourcePath); + let manifestEntry = await placesBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "PlacesBackupResource.backup should return null as its ManifestEntry" + ); Assert.ok( fakeConnection.backup.calledTwice, @@ -154,7 +172,16 @@ add_task(async function test_backup_no_saved_history() { Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, false); Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, false); - await placesBackupResource.backup(stagingPath, sourcePath); + let manifestEntry = await placesBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.deepEqual( + manifestEntry, + { bookmarksOnly: true }, + "Should have gotten back a ManifestEntry indicating that we only copied " + + "bookmarks" + ); Assert.ok( fakeConnection.backup.notCalled, @@ -171,7 +198,13 @@ add_task(async function test_backup_no_saved_history() { Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, true); fakeConnection.backup.resetHistory(); - await placesBackupResource.backup(stagingPath, sourcePath); + manifestEntry = await placesBackupResource.backup(stagingPath, sourcePath); + Assert.deepEqual( + manifestEntry, + { bookmarksOnly: true }, + "Should have gotten back a ManifestEntry indicating that we only copied " + + "bookmarks" + ); Assert.ok( fakeConnection.backup.notCalled, @@ -211,7 +244,16 @@ add_task(async function test_backup_private_browsing() { sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); sandbox.stub(PrivateBrowsingUtils, "permanentPrivateBrowsing").value(true); - await placesBackupResource.backup(stagingPath, sourcePath); + let manifestEntry = await placesBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.deepEqual( + manifestEntry, + { bookmarksOnly: true }, + "Should have gotten back a ManifestEntry indicating that we only copied " + + "bookmarks" + ); Assert.ok( fakeConnection.backup.notCalled, @@ -224,3 +266,104 @@ add_task(async function test_backup_private_browsing() { sandbox.restore(); }); + +/** + * Test that the recover method correctly copies places.sqlite and favicons.sqlite + * from the recovery directory into the destination profile directory. + */ +add_task(async function test_recover() { + let placesBackupResource = new PlacesBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-test-profile" + ); + + const simpleCopyFiles = [ + { path: "places.sqlite" }, + { path: "favicons.sqlite" }, + ]; + await createTestFiles(recoveryPath, simpleCopyFiles); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await placesBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "PlacesBackupResource.recover should return null as its post recovery entry" + ); + + await assertFilesExist(destProfilePath, simpleCopyFiles); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); + +/** + * Test that the recover method correctly copies bookmarks.jsonlz4 from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover_bookmarks_only() { + let sandbox = sinon.createSandbox(); + let placesBackupResource = new PlacesBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-test-profile" + ); + let bookmarksImportStub = sandbox + .stub(BookmarkJSONUtils, "importFromFile") + .resolves(true); + + await createTestFiles(recoveryPath, [{ path: "bookmarks.jsonlz4" }]); + + // The backup method is expected to detect bookmarks import only + let postRecoveryEntry = await placesBackupResource.recover( + { bookmarksOnly: true }, + recoveryPath, + destProfilePath + ); + + let expectedBookmarksPath = PathUtils.join(recoveryPath, "bookmarks.jsonlz4"); + + // Expect the bookmarks backup file path to be passed from recover() + Assert.deepEqual( + postRecoveryEntry, + { bookmarksBackupPath: expectedBookmarksPath }, + "PlacesBackupResource.recover should return the expected post recovery entry" + ); + + // Ensure that files stored in a places backup are not copied to the new profile during recovery + for (let placesFile of [ + "places.sqlite", + "favicons.sqlite", + "bookmarks.jsonlz4", + ]) { + Assert.ok( + !(await IOUtils.exists(PathUtils.join(destProfilePath, placesFile))), + `${placesFile} should not exist in the new profile` + ); + } + + // Now pretend that BackupService called the postRecovery method + await placesBackupResource.postRecovery(postRecoveryEntry); + Assert.ok( + bookmarksImportStub.calledOnce, + "BookmarkJSONUtils.importFromFile was called in the postRecovery step" + ); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js b/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js index 6845431bb8..2075b57e91 100644 --- a/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js +++ b/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js @@ -86,6 +86,14 @@ add_task(async function test_backup() { ]; await createTestFiles(sourcePath, simpleCopyFiles); + // Create our fake database files. We don't expect these to be copied to the + // staging directory in this test due to our stubbing of the backup method, so + // we don't include it in `simpleCopyFiles`. + await createTestFiles(sourcePath, [ + { path: "permissions.sqlite" }, + { path: "content-prefs.sqlite" }, + ]); + // 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 @@ -96,7 +104,15 @@ add_task(async function test_backup() { }; sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); - await preferencesBackupResource.backup(stagingPath, sourcePath); + let manifestEntry = await preferencesBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "PreferencesBackupResource.backup should return null as its ManifestEntry" + ); await assertFilesExist(stagingPath, simpleCopyFiles); @@ -130,3 +146,51 @@ add_task(async function test_backup() { sandbox.restore(); }); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let preferencesBackupResource = new PreferencesBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-test-profile" + ); + + const simpleCopyFiles = [ + { path: "prefs.js" }, + { path: "xulstore.json" }, + { path: "permissions.sqlite" }, + { path: "content-prefs.sqlite" }, + { 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(recoveryPath, simpleCopyFiles); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await preferencesBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "PreferencesBackupResource.recover should return null as its post recovery entry" + ); + + await assertFilesExist(destProfilePath, simpleCopyFiles); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_SessionStoreBackupResource.js b/browser/components/backup/tests/xpcshell/test_SessionStoreBackupResource.js new file mode 100644 index 0000000000..d57f2d3a25 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_SessionStoreBackupResource.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SessionStoreBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/SessionStoreBackupResource.sys.mjs" +); +const { SessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +/** + * Tests that we can measure the Session Store JSON and backups directory. + */ +add_task(async function test_measure() { + 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); +}); + +/** + * 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 sessionStoreBackupResource = new SessionStoreBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "SessionStoreBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "SessionStoreBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: ["sessionstore-backups", "test-sessionstore-backup.jsonlz4"] }, + { path: ["sessionstore-backups", "test-sessionstore-recovery.baklz4"] }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + let sessionStoreState = SessionStore.getCurrentState(true); + let manifestEntry = await sessionStoreBackupResource.backup( + stagingPath, + sourcePath + ); + Assert.equal( + manifestEntry, + null, + "SessionStoreBackupResource.backup should return null as its ManifestEntry" + ); + + /** + * We don't expect the actual file sessionstore.jsonlz4 to exist in the profile directory before calling the backup method. + * Instead, verify that it is created by the backup method and exists in the staging folder right after. + */ + await assertFilesExist(stagingPath, [ + ...simpleCopyFiles, + { path: "sessionstore.jsonlz4" }, + ]); + + /** + * Do a deep comparison between the recorded session state before backup and the file made in the staging folder + * to verify that information about session state was correctly written for backup. + */ + let sessionStoreStateStaged = await IOUtils.readJSON( + PathUtils.join(stagingPath, "sessionstore.jsonlz4"), + { decompress: true } + ); + + /** + * These timestamps might be slightly different from one another, so we'll exclude + * them from the comparison. + */ + delete sessionStoreStateStaged.session.lastUpdate; + delete sessionStoreState.session.lastUpdate; + Assert.deepEqual( + sessionStoreStateStaged, + sessionStoreState, + "sessionstore.jsonlz4 in the staging folder matches the recorded session state" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Test that the recover method correctly copies items from the recovery + * directory into the destination profile directory. + */ +add_task(async function test_recover() { + let sessionStoreBackupResource = new SessionStoreBackupResource(); + let recoveryPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "SessionStoreBackupResource-recovery-test" + ); + let destProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "SessionStoreBackupResource-test-profile" + ); + + const simpleCopyFiles = [ + { path: ["sessionstore-backups", "test-sessionstore-backup.jsonlz4"] }, + { path: ["sessionstore-backups", "test-sessionstore-recovery.baklz4"] }, + ]; + await createTestFiles(recoveryPath, simpleCopyFiles); + + // We backup a copy of sessionstore.jsonlz4, so ensure it exists in the recovery path + let sessionStoreState = SessionStore.getCurrentState(true); + let sessionStoreBackupPath = PathUtils.join( + recoveryPath, + "sessionstore.jsonlz4" + ); + await IOUtils.writeJSON(sessionStoreBackupPath, sessionStoreState, { + compress: true, + }); + + // The backup method is expected to have returned a null ManifestEntry + let postRecoveryEntry = await sessionStoreBackupResource.recover( + null /* manifestEntry */, + recoveryPath, + destProfilePath + ); + Assert.equal( + postRecoveryEntry, + null, + "SessionStoreBackupResource.recover should return null as its post recovery entry" + ); + + await assertFilesExist(destProfilePath, [ + ...simpleCopyFiles, + { path: "sessionstore.jsonlz4" }, + ]); + + let sessionStateCopied = await IOUtils.readJSON( + PathUtils.join(destProfilePath, "sessionstore.jsonlz4"), + { decompress: true } + ); + + /** + * These timestamps might be slightly different from one another, so we'll exclude + * them from the comparison. + */ + delete sessionStateCopied.session.lastUpdate; + delete sessionStoreState.session.lastUpdate; + Assert.deepEqual( + sessionStateCopied, + sessionStoreState, + "sessionstore.jsonlz4 in the destination profile folder matches the backed up session state" + ); + + await maybeRemovePath(recoveryPath); + await maybeRemovePath(destProfilePath); +}); diff --git a/browser/components/backup/tests/xpcshell/test_createBackup.js b/browser/components/backup/tests/xpcshell/test_createBackup.js deleted file mode 100644 index fcace695ef..0000000000 --- a/browser/components/backup/tests/xpcshell/test_createBackup.js +++ /dev/null @@ -1,74 +0,0 @@ -/* 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 deleted file mode 100644 index 0dece6b370..0000000000 --- a/browser/components/backup/tests/xpcshell/test_measurements.js +++ /dev/null @@ -1,577 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. -http://creativecommons.org/publicdomain/zero/1.0/ */ - -"use strict"; - -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 { SessionStoreBackupResource } = ChromeUtils.importESModule( - "resource:///modules/backup/SessionStoreBackupResource.sys.mjs" -); - -add_setup(() => { - // 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() { - let bs = new BackupService(); - await bs.takeMeasurements(); - let measurement = Glean.browserBackup.profDDiskSpace.testGetValue(); - TelemetryTestUtils.assertScalar( - TelemetryTestUtils.getProcessScalars("parent", false, true), - "browser.backup.prof_d_disk_space", - measurement - ); - - Assert.greater( - measurement, - 0, - "Should have collected a measurement for the profile directory storage " + - "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 07e517f1f2..8a41c9e761 100644 --- a/browser/components/backup/tests/xpcshell/xpcshell.toml +++ b/browser/components/backup/tests/xpcshell/xpcshell.toml @@ -6,15 +6,25 @@ prefs = [ "browser.backup.log=true", ] +["test_AddonsBackupResource.js"] + ["test_BackupResource.js"] support-files = ["data/test_xulstore.json"] +["test_BackupService.js"] + +["test_BackupService_takeMeasurements.js"] + +["test_CookiesBackupResource.js"] + +["test_CredentialsAndSecurityBackupResource.js"] + +["test_FormHistoryBackupResource.js"] + ["test_MiscDataBackupResource.js"] ["test_PlacesBackupResource.js"] ["test_PreferencesBackupResource.js"] -["test_createBackup.js"] - -["test_measurements.js"] +["test_SessionStoreBackupResource.js"] diff --git a/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs b/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs index c710f098cb..901059906a 100644 --- a/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs +++ b/browser/components/contentanalysis/content/ContentAnalysis.sys.mjs @@ -25,6 +25,7 @@ XPCOMUtils.defineLazyServiceGetter( ChromeUtils.defineESModuleGetters(lazy, { clearTimeout: "resource://gre/modules/Timer.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); @@ -211,7 +212,7 @@ export const ContentAnalysis = { * Registers for various messages/events that will indicate the * need for communicating something to the user. */ - initialize() { + initialize(doc) { if (!this.isInitialized) { this.isInitialized = true; this.initializeDownloadCA(); @@ -223,6 +224,17 @@ export const ContentAnalysis = { ); }); } + + // Do this even if initialized so the icon shows up on new windows, not just the + // first one. + if (lazy.gContentAnalysis.isActive) { + doc.l10n.setAttributes( + doc.getElementById("content-analysis-indicator"), + "content-analysis-indicator-tooltip", + { agentName: lazy.agentName } + ); + doc.documentElement.setAttribute("contentanalysisactive", "true"); + } }, async uninitialize() { @@ -390,6 +402,18 @@ export const ContentAnalysis = { } }, + async showPanel(element, panelUI) { + element.ownerDocument.l10n.setAttributes( + lazy.PanelMultiView.getViewNode( + element.ownerDocument, + "content-analysis-panel-description" + ), + "content-analysis-panel-text", + { agentName: lazy.agentName } + ); + panelUI.showSubView("content-analysis-panel", element); + }, + _showAnotherPendingDialog(aBrowsingContext) { const otherBrowsingContext = this.dlpBusyViewsByTopBrowsingContext.getBrowsingContextWithPendingNotification( @@ -618,6 +642,36 @@ export const ContentAnalysis = { }); }, + _getErrorDialogMessage(aResourceNameOrOperationType) { + if (aResourceNameOrOperationType.name) { + return this.l10n.formatValueSync( + "contentanalysis-error-message-upload-file", + { + filename: aResourceNameOrOperationType.name, + } + ); + } + let l10nId = undefined; + switch (aResourceNameOrOperationType.operationType) { + case Ci.nsIContentAnalysisRequest.eClipboard: + l10nId = "contentanalysis-error-message-clipboard"; + break; + case Ci.nsIContentAnalysisRequest.eDroppedText: + l10nId = "contentanalysis-error-message-dropped-text"; + break; + case Ci.nsIContentAnalysisRequest.eOperationPrint: + l10nId = "contentanalysis-error-message-print"; + break; + } + if (!l10nId) { + console.error( + "Unknown operationTypeForDisplay: ", + aResourceNameOrOperationType + ); + return ""; + } + return this.l10n.formatValueSync(l10nId); + }, _showSlowCABlockingMessage( aBrowsingContext, aRequestToken, @@ -710,7 +764,7 @@ export const ContentAnalysis = { Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + Ci.nsIPromptService.BUTTON_POS_1 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + - Ci.nsIPromptService.BUTTON_POS_1_DEFAULT, + Ci.nsIPromptService.BUTTON_POS_2_DEFAULT, await this.l10n.formatValue( "contentanalysis-warndialog-response-allow" ), @@ -725,26 +779,60 @@ export const ContentAnalysis = { lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow); return null; } - case Ci.nsIContentAnalysisResponse.eBlock: + 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 - ), - }); - timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; - break; + let titleId = undefined; + let body = undefined; + if (aResourceNameOrOperationType.name) { + titleId = "contentanalysis-block-dialog-title-upload-file"; + body = this.l10n.formatValueSync( + "contentanalysis-block-dialog-body-upload-file", + { + filename: aResourceNameOrOperationType.name, + } + ); + } else { + let bodyId = undefined; + switch (aResourceNameOrOperationType.operationType) { + case Ci.nsIContentAnalysisRequest.eClipboard: + titleId = "contentanalysis-block-dialog-title-clipboard"; + bodyId = "contentanalysis-block-dialog-body-clipboard"; + break; + case Ci.nsIContentAnalysisRequest.eDroppedText: + titleId = "contentanalysis-block-dialog-title-dropped-text"; + bodyId = "contentanalysis-block-dialog-body-dropped-text"; + break; + case Ci.nsIContentAnalysisRequest.eOperationPrint: + titleId = "contentanalysis-block-dialog-title-print"; + bodyId = "contentanalysis-block-dialog-body-print"; + break; + } + if (!titleId || !bodyId) { + console.error( + "Unknown operationTypeForDisplay: ", + aResourceNameOrOperationType + ); + return null; + } + body = this.l10n.formatValueSync(bodyId); + } + Services.prompt.alertBC( + aBrowsingContext, + Ci.nsIPromptService.MODAL_TYPE_TAB, + this.l10n.formatValueSync(titleId), + body + ); + return null; + } case Ci.nsIContentAnalysisResponse.eUnspecified: message = await this.l10n.formatValue( - "contentanalysis-unspecified-error-message", + "contentanalysis-unspecified-error-message-content", { agent: lazy.agentName, - content: this._getResourceNameFromNameOrOperationType( - aResourceNameOrOperationType - ), + content: this._getErrorDialogMessage(aResourceNameOrOperationType), } ); timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; @@ -759,26 +847,25 @@ export const ContentAnalysis = { ); return null; case Ci.nsIContentAnalysisResponse.eNoAgent: - messageId = "contentanalysis-no-agent-connected-message"; + messageId = "contentanalysis-no-agent-connected-message-content"; break; case Ci.nsIContentAnalysisResponse.eInvalidAgentSignature: - messageId = "contentanalysis-invalid-agent-signature-message"; + messageId = + "contentanalysis-invalid-agent-signature-message-content"; break; case Ci.nsIContentAnalysisResponse.eErrorOther: - messageId = "contentanalysis-unspecified-error-message"; + messageId = "contentanalysis-unspecified-error-message-content"; break; default: console.error( "Unexpected CA cancelError value: " + aRequestCancelError ); - messageId = "contentanalysis-unspecified-error-message"; + messageId = "contentanalysis-unspecified-error-message-content"; break; } message = await this.l10n.formatValue(messageId, { agent: lazy.agentName, - content: this._getResourceNameFromNameOrOperationType( - aResourceNameOrOperationType - ), + content: this._getErrorDialogMessage(aResourceNameOrOperationType), }); timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; } diff --git a/browser/components/contextualidentity/content/usercontext.css b/browser/components/contextualidentity/content/usercontext.css index f12625c08f..52e65ade1f 100644 --- a/browser/components/contextualidentity/content/usercontext.css +++ b/browser/components/contextualidentity/content/usercontext.css @@ -106,8 +106,12 @@ } #userContext-label { - margin: 0; - color: var(--identity-tab-color); + color: var(--identity-tab-color); + margin: 0; + max-width: 8em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } #userContext-icons { diff --git a/browser/components/customizableui/CustomizableWidgets.sys.mjs b/browser/components/customizableui/CustomizableWidgets.sys.mjs index 1f37af7963..09b57be4c9 100644 --- a/browser/components/customizableui/CustomizableWidgets.sys.mjs +++ b/browser/components/customizableui/CustomizableWidgets.sys.mjs @@ -99,6 +99,21 @@ export const CustomizableWidgets = [ case "unload": this.onWindowUnload(event); break; + case "command": { + let { target } = event; + let { PanelUI, PlacesCommandHook } = target.ownerGlobal; + if (target.id == "appMenuRecentlyClosedTabs") { + PanelUI.showSubView(this.recentlyClosedTabsPanel, target); + } else if (target.id == "appMenuRecentlyClosedWindows") { + PanelUI.showSubView(this.recentlyClosedWindowsPanel, target); + } else if (target.id == "appMenuSearchHistory") { + PlacesCommandHook.searchHistory(); + } else if (target.id == "PanelUI-historyMore") { + PlacesCommandHook.showPlacesOrganizer("History"); + lazy.CustomizableUI.hidePanelForNode(target); + } + break; + } default: throw new Error(`Unsupported event for '${this.id}'`); } @@ -153,6 +168,7 @@ export const CustomizableWidgets = [ // When the popup is hidden (thus the panelmultiview node as well), make // sure to stop listening to PlacesDatabase updates. panelview.panelMultiView.addEventListener("PanelMultiViewHidden", this); + panelview.addEventListener("command", this); window.addEventListener("unload", this); }, onViewHiding() { @@ -172,6 +188,10 @@ export const CustomizableWidgets = [ document, this.recentlyClosedWindowsPanel ).removeEventListener("ViewShowing", this); + lazy.PanelMultiView.getViewNode( + document, + this.viewId + ).removeEventListener("command", this); } panelMultiView.removeEventListener("PanelMultiViewHidden", this); }, @@ -260,7 +280,7 @@ export const CustomizableWidgets = [ tooltiptext: "sidebar-button.tooltiptext2", onCommand(aEvent) { let win = aEvent.target.ownerGlobal; - win.SidebarUI.toggle(); + win.SidebarController.toggle(); }, onCreated(aNode) { // Add an observer so the button is checked while the sidebar is open @@ -419,7 +439,7 @@ export const CustomizableWidgets = [ id: "characterencoding-button", l10nId: "repair-text-encoding-button", onCommand(aEvent) { - aEvent.view.BrowserForceEncodingDetection(); + aEvent.view.BrowserCommands.forceEncodingDetection(); }, }, { @@ -464,10 +484,52 @@ if (Services.prefs.getBoolPref("identity.fxaccounts.enabled")) { lazy.PanelMultiView.getViewNode(doc, "PanelUI-remotetabs-deck"), lazy.PanelMultiView.getViewNode(doc, "PanelUI-remotetabs-tabslist") ); + panelview.addEventListener("command", this); + let syncNowButton = lazy.PanelMultiView.getViewNode( + aEvent.target.ownerDocument, + "PanelUI-remotetabs-syncnow" + ); + syncNowButton.addEventListener("mouseover", this); }, onViewHiding(aEvent) { - aEvent.target.syncedTabsPanelList.destroy(); - aEvent.target.syncedTabsPanelList = null; + let panelview = aEvent.target; + panelview.syncedTabsPanelList.destroy(); + panelview.syncedTabsPanelList = null; + panelview.removeEventListener("command", this); + let syncNowButton = lazy.PanelMultiView.getViewNode( + aEvent.target.ownerDocument, + "PanelUI-remotetabs-syncnow" + ); + syncNowButton.removeEventListener("mouseover", this); + }, + handleEvent(aEvent) { + let button = aEvent.target; + let { gSync } = button.ownerGlobal; + switch (aEvent.type) { + case "mouseover": + gSync.refreshSyncButtonsTooltip(); + break; + case "command": { + switch (button.id) { + case "PanelUI-remotetabs-syncnow": + gSync.doSync(); + break; + case "PanelUI-remotetabs-view-managedevices": + gSync.openDevicesManagementPage("syncedtabs-menupanel"); + break; + case "PanelUI-remotetabs-tabsdisabledpane-button": + case "PanelUI-remotetabs-setupsync-button": + case "PanelUI-remotetabs-syncdisabled-button": + case "PanelUI-remotetabs-reauthsync-button": + case "PanelUI-remotetabs-unverified-button": + gSync.openPrefs("synced-tabs"); + break; + case "PanelUI-remotetabs-connect-device-button": + gSync.openConnectAnotherDevice("synced-tabs"); + break; + } + } + } }, }); } diff --git a/browser/components/customizableui/CustomizeMode.sys.mjs b/browser/components/customizableui/CustomizeMode.sys.mjs index 7b4ee373be..41f347130e 100644 --- a/browser/components/customizableui/CustomizeMode.sys.mjs +++ b/browser/components/customizableui/CustomizeMode.sys.mjs @@ -1376,7 +1376,7 @@ CustomizeMode.prototype = { }, openAddonsManagerThemes() { - this.window.BrowserOpenAddonsMgr("addons://list/theme"); + this.window.BrowserAddonUI.openAddonsMgr("addons://list/theme"); }, getMoreThemes(aEvent) { diff --git a/browser/components/customizableui/content/panelUI.inc.xhtml b/browser/components/customizableui/content/panelUI.inc.xhtml index 956a6ae45d..7607f59ec4 100644 --- a/browser/components/customizableui/content/panelUI.inc.xhtml +++ b/browser/components/customizableui/content/panelUI.inc.xhtml @@ -107,7 +107,7 @@ <toolbarbutton id="unified-extensions-manage-extensions" class="subviewbutton panel-subview-footer-button unified-extensions-manage-extensions" data-l10n-id="unified-extensions-manage-extensions" - oncommand="BrowserOpenAddonsMgr('addons://list/extension');" /> + oncommand="BrowserAddonUI.openAddonsMgr('addons://list/extension');" /> </panelview> </panelmultiview> </panel> @@ -271,7 +271,7 @@ flip="slide" position="bottomright topright" noautofocus="true"> - <panelmultiview id="appMenu-multiView" mainViewId="appMenu-protonMainView" + <panelmultiview id="appMenu-multiView" mainViewId="appMenu-mainView" viewCacheId="appMenu-viewCache"> </panelmultiview> </panel> diff --git a/browser/components/customizableui/content/panelUI.js b/browser/components/customizableui/content/panelUI.js index cb32085fd7..cbd27e465e 100644 --- a/browser/components/customizableui/content/panelUI.js +++ b/browser/components/customizableui/content/panelUI.js @@ -128,10 +128,16 @@ const PanelUI = { this.panel.addEventListener(event, this); } + this._onLibraryCommand = this._onLibraryCommand.bind(this); PanelMultiView.getViewNode(document, "PanelUI-helpView").addEventListener( "ViewShowing", this._onHelpViewShow ); + PanelMultiView.getViewNode( + document, + "appMenu-libraryView" + ).addEventListener("command", this._onLibraryCommand); + this.mainView.addEventListener("command", this); this._eventListenersAdded = true; }, @@ -143,6 +149,11 @@ const PanelUI = { document, "PanelUI-helpView" ).removeEventListener("ViewShowing", this._onHelpViewShow); + PanelMultiView.getViewNode( + document, + "appMenu-libraryView" + ).removeEventListener("command", this._onLibraryCommand); + this.mainView.removeEventListener("command", this); this._eventListenersAdded = false; }, @@ -299,6 +310,54 @@ const PanelUI = { case "activate": this.updateNotifications(); break; + case "command": + this.onCommand(aEvent); + break; + } + }, + + // Note that we listen for bubbling command events. In the case where the + // button that the user clicks has a command attribute, those events are + // redirected to the relevant command element, and we never see them in + // here. Bear this in mind if you want to write code that applies to + // all commands, for which this wouldn't work well. + onCommand(aEvent) { + let { target } = aEvent; + switch (target.id) { + case "appMenu-update-banner": + this._onBannerItemSelected(aEvent); + break; + case "appMenu-fxa-label2": + gSync.toggleAccountPanel(target, aEvent); + break; + case "appMenu-profiles-button": + gProfiles.updateView(target); + break; + case "appMenu-bookmarks-button": + BookmarkingUI.showSubView(target); + break; + case "appMenu-history-button": + this.showSubView("PanelUI-history", target); + break; + case "appMenu-passwords-button": + LoginHelper.openPasswordManager(window, { entryPoint: "mainmenu" }); + break; + case "appMenu-fullscreen-button2": + // Note that we're custom-handling the hiding of the panel to make + // sure it disappears before entering fullscreen. Otherwise it can + // end up moving around on the screen during the fullscreen transition. + target.closest("panel").hidePopup(); + setTimeout(() => BrowserCommands.fullScreen(), 0); + break; + case "appMenu-settings-button": + openPreferences(); + break; + case "appMenu-more-button2": + this.showMoreToolsPanel(target); + break; + case "appMenu-help-button2": + this.showSubView("PanelUI-helpView", target); + break; } }, @@ -632,6 +691,22 @@ const PanelUI = { items.appendChild(fragment); }, + _onLibraryCommand(aEvent) { + let button = aEvent.target; + let { BookmarkingUI, DownloadsPanel } = button.ownerGlobal; + switch (button.id) { + case "appMenu-library-bookmarks-button": + BookmarkingUI.showSubView(button); + break; + case "appMenu-library-history-button": + this.showSubView("PanelUI-history", button); + break; + case "appMenu-library-downloads-button": + DownloadsPanel.showDownloadsHistory(); + break; + } + }, + _hidePopup() { if (!this._notificationPanel) { return; @@ -840,10 +915,7 @@ const PanelUI = { get mainView() { if (!this._mainView) { - this._mainView = PanelMultiView.getViewNode( - document, - "appMenu-protonMainView" - ); + this._mainView = PanelMultiView.getViewNode(document, "appMenu-mainView"); } return this._mainView; }, @@ -852,7 +924,7 @@ const PanelUI = { if (!this._addonNotificationContainer) { this._addonNotificationContainer = PanelMultiView.getViewNode( document, - "appMenu-proton-addon-banners" + "appMenu-addon-banners" ); } diff --git a/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js b/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js index b41fc2ef23..d8c687d88a 100644 --- a/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js +++ b/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js @@ -150,7 +150,6 @@ add_setup(async function () { gLink.innerText = "gLink"; gLink.id = "gLink"; gMainView.appendChild(gLink); - await window.ensureCustomElements("moz-toggle"); gToggle = document.createElement("moz-toggle"); gToggle.label = "Test label"; gMainView.appendChild(gToggle); diff --git a/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js index 9377c28950..92528f2537 100644 --- a/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js +++ b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js @@ -13,8 +13,8 @@ add_task(async function test_appMenu_mainView() { return; } - let mainViewID = "appMenu-protonMainView"; - const mainView = document.getElementById(mainViewID); + let mainViewID = "appMenu-mainView"; + const mainView = PanelMultiView.getViewNode(document, mainViewID); let shownPromise = BrowserTestUtils.waitForEvent(mainView, "ViewShown"); // Should still open the panel when Ctrl key is pressed. diff --git a/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js index df856dd4cf..686d600601 100644 --- a/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js +++ b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js @@ -24,7 +24,7 @@ add_task(async function testBannerVisibilityBeforeOpen() { menuButton.click(); await shown; - let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + let banner = newWin.document.getElementById("appMenu-update-banner"); let labelPromise = BrowserTestUtils.waitForMutationCondition( banner, @@ -62,7 +62,7 @@ add_task(async function testBannerVisibilityDuringOpen() { menuButton.click(); await shown; - let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + let banner = newWin.document.getElementById("appMenu-update-banner"); ok( !banner.hasAttribute("label"), "Update banner shouldn't contain text before notification" @@ -109,7 +109,7 @@ add_task(async function testBannerVisibilityAfterClose() { ok(newWin.PanelUI.mainView.hasAttribute("visible")); - let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + let banner = newWin.document.getElementById("appMenu-update-banner"); ok(banner.hidden, "Update banner should be hidden before notification"); ok( diff --git a/browser/components/customizableui/test/browser_sidebar_toggle.js b/browser/components/customizableui/test/browser_sidebar_toggle.js index 5742f368ee..a063cc26cf 100644 --- a/browser/components/customizableui/test/browser_sidebar_toggle.js +++ b/browser/components/customizableui/test/browser_sidebar_toggle.js @@ -9,7 +9,7 @@ registerCleanupFunction(async function () { // Ensure sidebar is hidden after each test: if (!document.getElementById("sidebar-box").hidden) { - SidebarUI.hide(); + SidebarController.hide(); } }); @@ -21,14 +21,14 @@ var showSidebar = async function (win = window) { ); EventUtils.synthesizeMouseAtCenter(button, {}, win); await sidebarFocusedPromise; - ok(win.SidebarUI.isOpen, "Sidebar is opened"); + ok(win.SidebarController.isOpen, "Sidebar is opened"); ok(button.hasAttribute("checked"), "Toolbar button is checked"); }; var hideSidebar = async function (win = window) { let button = win.document.getElementById("sidebar-button"); EventUtils.synthesizeMouseAtCenter(button, {}, win); - ok(!win.SidebarUI.isOpen, "Sidebar is closed"); + ok(!win.SidebarController.isOpen, "Sidebar is closed"); ok(!button.hasAttribute("checked"), "Toolbar button isn't checked"); }; @@ -37,18 +37,26 @@ add_task(async function () { CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar"); await showSidebar(); - is(SidebarUI.currentID, "viewBookmarksSidebar", "Default sidebar selected"); - await SidebarUI.show("viewHistorySidebar"); + is( + SidebarController.currentID, + "viewBookmarksSidebar", + "Default sidebar selected" + ); + await SidebarController.show("viewHistorySidebar"); await hideSidebar(); await showSidebar(); - is(SidebarUI.currentID, "viewHistorySidebar", "Selected sidebar remembered"); + is( + SidebarController.currentID, + "viewHistorySidebar", + "Selected sidebar remembered" + ); await hideSidebar(); let otherWin = await BrowserTestUtils.openNewBrowserWindow(); await showSidebar(otherWin); is( - otherWin.SidebarUI.currentID, + otherWin.SidebarController.currentID, "viewHistorySidebar", "Selected sidebar remembered across windows" ); diff --git a/browser/components/customizableui/test/browser_synced_tabs_menu.js b/browser/components/customizableui/test/browser_synced_tabs_menu.js index 33c8f6a845..c99223a80e 100644 --- a/browser/components/customizableui/test/browser_synced_tabs_menu.js +++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js @@ -216,6 +216,17 @@ add_task(async function () { ok(button, "found the button"); await document.getElementById("nav-bar").overflowable.show(); + // Actually show the fxa view: + let shown = BrowserTestUtils.waitForEvent( + document.getElementById("PanelUI-remotetabs"), + "ViewShown" + ); + PanelUI.showSubView( + "PanelUI-remotetabs", + document.getElementById("sync-button") + ); + await shown; + let expectedUrl = "https://example.com/connect_another_device?context=" + "fx_desktop_v3&entrypoint=synced-tabs&service=sync&uid=uid&email=foo%40bar.com"; @@ -325,20 +336,28 @@ add_task(async function () { node = node.firstElementChild; is(node.getAttribute("itemtype"), "client", "node is a client entry"); is(node.textContent, "My Desktop", "correct client"); - // Next entry is the most-recent tab + // Next node is an hbox, that contains the tab and potentially + // a button for closing the tab remotely node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/10"); + is(node.nodeName, "hbox"); + // Next entry is the most-recent tab + let childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/10"); // Next entry is the next-most-recent tab node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/5"); + is(node.nodeName, "hbox"); + childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/5"); // Next entry is the least-recent tab from the first client. node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/1"); + is(node.nodeName, "hbox"); + childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/1"); node = node.nextElementSibling; is(node, null, "no more siblings"); @@ -357,8 +376,10 @@ add_task(async function () { is(node.textContent, "My Other Desktop", "correct client"); // Its single tab node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/6"); + is(node.nodeName, "hbox"); + childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/6"); node = node.nextElementSibling; is(node, null, "no more siblings"); @@ -468,14 +489,16 @@ add_task(async function () { is(node.textContent, "My Desktop", "correct client"); for (let i = 0; i < tabsShownCount; i++) { node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.nodeName, "hbox"); + let childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); is( - node.getAttribute("label"), + childNode.getAttribute("label"), "Tab #" + (i + 1), "the tab is the correct one" ); is( - node.getAttribute("targetURI"), + childNode.getAttribute("targetURI"), SAMPLE_TAB_URL, "url is the correct one" ); @@ -498,7 +521,9 @@ add_task(async function () { async function checkCanOpenURL() { let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); - let node = tabList.firstElementChild.firstElementChild.nextElementSibling; + let node = + tabList.firstElementChild.firstElementChild.nextElementSibling + .firstElementChild; let promiseTabOpened = BrowserTestUtils.waitForLocationChange( gBrowser, SAMPLE_TAB_URL diff --git a/browser/components/distribution.sys.mjs b/browser/components/distribution.sys.mjs index 369de15ab2..f58fd7a419 100644 --- a/browser/components/distribution.sys.mjs +++ b/browser/components/distribution.sys.mjs @@ -247,21 +247,10 @@ DistributionCustomizer.prototype = { if (item.icon && item.iconData) { try { - let faviconURI = Services.io.newURI(item.icon); - lazy.PlacesUtils.favicons.replaceFaviconDataFromDataURL( - faviconURI, - item.iconData, - 0, - Services.scriptSecurityManager.getSystemPrincipal() - ); - - lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + lazy.PlacesUtils.favicons.setFaviconForPage( Services.io.newURI(item.link), - faviconURI, - false, - lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, - null, - Services.scriptSecurityManager.getSystemPrincipal() + Services.io.newURI(item.icon), + Services.io.newURI(item.iconData) ); } catch (e) { console.error(e); diff --git a/browser/components/downloads/content/allDownloadsView.js b/browser/components/downloads/content/allDownloadsView.js index 08f8bfcb5f..f880f602aa 100644 --- a/browser/components/downloads/content/allDownloadsView.js +++ b/browser/components/downloads/content/allDownloadsView.js @@ -141,7 +141,7 @@ HistoryDownloadElementShell.prototype = { // be opened. let browserWin = BrowserWindowTracker.getTopWindow(); let openWhere = browserWin - ? browserWin.whereToOpenLink(event, false, true) + ? BrowserUtils.whereToOpenLink(event, false, true) : "window"; if (["window", "tabshifted", "tab"].includes(openWhere)) { command += ":" + openWhere; diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js index b420d48db6..cab8b65aab 100644 --- a/browser/components/downloads/content/downloads.js +++ b/browser/components/downloads/content/downloads.js @@ -97,8 +97,6 @@ var DownloadsPanel = { window.addEventListener("unload", this.onWindowUnload); - window.ensureCustomElements("moz-button-group"); - // Load and resume active downloads if required. If there are downloads to // be shown in the panel, they will be loaded asynchronously. DownloadsCommon.initializeAllDataLinks(); @@ -327,7 +325,7 @@ var DownloadsPanel = { // to the browser window when the panel closes automatically. this.hidePanel(); - BrowserDownloadsUI(); + BrowserCommands.downloadsUI(); }, // Internal functions @@ -868,7 +866,7 @@ var DownloadsView = { } else if (aEvent.shiftKey || aEvent.ctrlKey || aEvent.metaKey) { // We adjust the command for supported modifiers to suggest where the download // may be opened - let openWhere = target.ownerGlobal.whereToOpenLink(aEvent, false, true); + let openWhere = BrowserUtils.whereToOpenLink(aEvent, false, true); if (["tab", "window", "tabshifted"].includes(openWhere)) { command += ":" + openWhere; } diff --git a/browser/components/enterprisepolicies/Policies.sys.mjs b/browser/components/enterprisepolicies/Policies.sys.mjs index fd15c244cc..ebc1bc0a58 100644 --- a/browser/components/enterprisepolicies/Policies.sys.mjs +++ b/browser/components/enterprisepolicies/Policies.sys.mjs @@ -549,10 +549,26 @@ export var Policies = { param.ClientSignature ); } + if ("DefaultResult" in param) { + if ( + !Number.isInteger(param.DefaultResult) || + param.DefaultResult < 0 || + param.DefaultResult > 2 + ) { + lazy.log.error( + `Non-integer or out of range value for DefaultResult: ${param.DefaultResult}` + ); + } else { + setAndLockPref( + "browser.contentanalysis.default_result", + param.DefaultResult + ); + } + } let boolPrefs = [ ["IsPerUser", "is_per_user"], ["ShowBlockedResult", "show_blocked_result"], - ["DefaultAllow", "default_allow"], + ["BypassForSameTabOperations", "bypass_for_same_tab_operations"], ]; for (let pref of boolPrefs) { if (pref[0] in param) { @@ -773,6 +789,15 @@ export var Policies = { }, }, + DisableEncryptedClientHello: { + onBeforeAddons(manager, param) { + if (param) { + setAndLockPref("network.dns.echconfig.enabled", false); + setAndLockPref("network.dns.http3_echconfig.enabled", false); + } + }, + }, + DisableFeedbackCommands: { onBeforeUIStartup(manager, param) { if (param) { @@ -1515,6 +1540,31 @@ export var Policies = { }, }, + HttpAllowlist: { + onBeforeAddons(manager, param) { + addAllowDenyPermissions("https-only-load-insecure", param); + }, + }, + + HttpsOnlyMode: { + onBeforeAddons(manager, param) { + switch (param) { + case "disallowed": + setAndLockPref("dom.security.https_only_mode", false); + break; + case "enabled": + PoliciesUtils.setDefaultPref("dom.security.https_only_mode", true); + break; + case "force_enabled": + setAndLockPref("dom.security.https_only_mode", true); + break; + case "allowed": + // The default case. + break; + } + }, + }, + InstallAddonsPermission: { onBeforeUIStartup(manager, param) { if ("Allow" in param) { @@ -1787,6 +1837,12 @@ export var Policies = { }, }, + PostQuantumKeyAgreementEnabled: { + onBeforeAddons(manager, param) { + setAndLockPref("security.tls.enable_kyber", param); + }, + }, + Preferences: { onBeforeAddons(manager, param) { let allowedPrefixes = [ @@ -1811,6 +1867,7 @@ export var Policies = { "places.", "pref.", "print.", + "privacy.globalprivacycontrol.enabled", "privacy.userContext.enabled", "privacy.userContext.ui.enabled", "signon.", @@ -1831,6 +1888,8 @@ export var Policies = { "security.insecure_connection_text.enabled", "security.insecure_connection_text.pbmode.enabled", "security.mixed_content.block_active_content", + "security.mixed_content.block_display_content", + "security.mixed_content.upgrade_display_content", "security.osclientcerts.assume_rsa_pss_support", "security.osclientcerts.autoload", "security.OCSP.enabled", @@ -2457,13 +2516,6 @@ export var Policies = { UserMessaging: { onBeforeAddons(manager, param) { - if ("WhatsNew" in param) { - PoliciesUtils.setDefaultPref( - "browser.messaging-system.whatsNewPanel.enabled", - param.WhatsNew, - param.Locked - ); - } if ("ExtensionRecommendations" in param) { PoliciesUtils.setDefaultPref( "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", diff --git a/browser/components/enterprisepolicies/content/aboutPolicies.html b/browser/components/enterprisepolicies/content/aboutPolicies.html index bf0a962aa8..0b7a4e78ff 100644 --- a/browser/components/enterprisepolicies/content/aboutPolicies.html +++ b/browser/components/enterprisepolicies/content/aboutPolicies.html @@ -24,7 +24,6 @@ rel="localization" href="browser/policies/policies-descriptions.ftl" /> - <link rel="localization" href="toolkit/branding/accounts.ftl" /> <link rel="localization" href="toolkit/branding/brandings.ftl" /> <script src="chrome://browser/content/policies/aboutPolicies.js"></script> </head> diff --git a/browser/components/enterprisepolicies/content/aboutPolicies.js b/browser/components/enterprisepolicies/content/aboutPolicies.js index 9cde085f3d..2de9be0982 100644 --- a/browser/components/enterprisepolicies/content/aboutPolicies.js +++ b/browser/components/enterprisepolicies/content/aboutPolicies.js @@ -294,6 +294,7 @@ function generateDocumentation() { SanitizeOnShutdown: "SanitizeOnShutdown2", WindowsSSO: "Windows10SSO", SecurityDevices: "SecurityDevices2", + DisableFirefoxAccounts: "DisableFirefoxAccounts1", }; for (let policyName in schema.properties) { diff --git a/browser/components/enterprisepolicies/helpers/BookmarksPolicies.sys.mjs b/browser/components/enterprisepolicies/helpers/BookmarksPolicies.sys.mjs index 5fc70c31cf..2e52a248c8 100644 --- a/browser/components/enterprisepolicies/helpers/BookmarksPolicies.sys.mjs +++ b/browser/components/enterprisepolicies/helpers/BookmarksPolicies.sys.mjs @@ -204,44 +204,30 @@ async function insertBookmark(bookmark) { } function setFaviconForBookmark(bookmark) { - let faviconURI; - let nullPrincipal = Services.scriptSecurityManager.createNullPrincipal({}); - switch (bookmark.Favicon.protocol) { - case "data:": - // data urls must first call replaceFaviconDataFromDataURL, using a - // fake URL. Later, it's needed to call setAndFetchFaviconForPage - // with the same URL. - faviconURI = Services.io.newURI("fake-favicon-uri:" + bookmark.URL.href); - - lazy.PlacesUtils.favicons.replaceFaviconDataFromDataURL( - faviconURI, - bookmark.Favicon.href, - 0 /* max expiration length */, - nullPrincipal + case "data:": { + lazy.PlacesUtils.favicons.setFaviconForPage( + bookmark.URL.URI, + Services.io.newURI("fake-favicon-uri:" + bookmark.URL.href), + bookmark.Favicon.URI ); - break; - + return; + } case "http:": - case "https:": - faviconURI = Services.io.newURI(bookmark.Favicon.href); - break; - - default: - lazy.log.error( - `Bad URL given for favicon on bookmark "${bookmark.Title}"` + case "https:": { + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + bookmark.URL.URI, + bookmark.Favicon.URI, + false /* forceReload */, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.createNullPrincipal({}) ); return; + } } - lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( - Services.io.newURI(bookmark.URL.href), - faviconURI, - false /* forceReload */, - lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, - null, - nullPrincipal - ); + lazy.log.error(`Bad URL given for favicon on bookmark "${bookmark.Title}"`); } // Cache of folder names to guids to be used by the getParentGuid diff --git a/browser/components/enterprisepolicies/schemas/policies-schema.json b/browser/components/enterprisepolicies/schemas/policies-schema.json index 3c578f2c4b..7058efb698 100644 --- a/browser/components/enterprisepolicies/schemas/policies-schema.json +++ b/browser/components/enterprisepolicies/schemas/policies-schema.json @@ -265,7 +265,10 @@ "ShowBlockedResult": { "type": "boolean" }, - "DefaultAllow": { + "DefaultResult": { + "type": "number" + }, + "BypassForSameTabOperations": { "type": "boolean" } } @@ -426,6 +429,10 @@ "type": "boolean" }, + "DisableEncryptedClientHello": { + "type": "boolean" + }, + "DisableFeedbackCommands": { "type": "boolean" }, @@ -832,6 +839,19 @@ } }, + "HttpAllowlist": { + "type": "array", + "strict": false, + "items": { + "type": "origin" + } + }, + + "HttpsOnlyMode": { + "type": "string", + "enum": ["allowed", "disallowed", "enabled", "force_enabled"] + }, + "InstallAddonsPermission": { "type": "object", "properties": { @@ -1180,6 +1200,10 @@ } }, + "PostQuantumKeyAgreementEnabled": { + "type": "boolean" + }, + "Preferences": { "type": ["object", "JSON"], "patternProperties": { diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_extensions.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_extensions.js index 7d1313548b..6b8feb4d82 100644 --- a/browser/components/enterprisepolicies/tests/browser/browser_policy_extensions.js +++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_extensions.js @@ -40,7 +40,7 @@ add_task(async function test_addon_install() { add_task(async function test_addon_locked() { let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); - const win = await BrowserOpenAddonsMgr("addons://list/extension"); + const win = await BrowserAddonUI.openAddonsMgr("addons://list/extension"); await isExtensionLocked(win, ADDON_ID); diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js index 612448ee4e..dd610ec7e5 100644 --- a/browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js +++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js @@ -45,7 +45,7 @@ add_task(async function test_addon_install() { add_task(async function test_addon_locked_update_disabled() { let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); - const win = await BrowserOpenAddonsMgr( + const win = await BrowserAddonUI.openAddonsMgr( "addons://detail/" + encodeURIComponent(ADDON_ID) ); diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js index 872eb5a652..87fb072971 100644 --- a/browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js +++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js @@ -7,6 +7,25 @@ let { LoginTestUtils } = ChromeUtils.importESModule( "resource://testing-common/LoginTestUtils.sys.mjs" ); +let { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +let { FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +); + +add_setup(async function () { + // Stub these out so we don't end up invoking the MP dialog + // in order to decrypt prefs to find out if these are enabled or disabled. + sinon.stub(FormAutofillUtils, "getOSAuthEnabled").returns(false); + sinon.stub(LoginHelper, "getOSAuthEnabled").returns(false); + + registerCleanupFunction(async function () { + sinon.restore(); + }); +}); + // Test that once a password is set, you can't unset it add_task(async function test_policy_masterpassword_set() { await setupPolicyEngineWithJson({ diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js b/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js index 22a6269cce..76157c7e97 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js @@ -8,10 +8,14 @@ const { AddonTestUtils } = ChromeUtils.importESModule( const { AddonManager } = ChromeUtils.importESModule( "resource://gre/modules/AddonManager.sys.mjs" ); +const { ExtensionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs" +); AddonTestUtils.init(this); AddonTestUtils.overrideCertDB(); AddonTestUtils.appInfo = getAppInfo(); +ExtensionTestUtils.init(this); const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); const BASE_URL = `http://example.com/data`; @@ -21,6 +25,34 @@ let themeID = "policytheme@mozilla.com"; let fileURL; +async function assertManagementAPIInstallType(addonId, expectedInstallType) { + const addon = await AddonManager.getAddonByID(addonId); + const expectInstalledByPolicy = expectedInstallType === "admin"; + equal( + addon.isInstalledByEnterprisePolicy, + expectInstalledByPolicy, + `Addon should ${ + expectInstalledByPolicy ? "be" : "NOT be" + } marked as installed by enterprise policy` + ); + const policy = WebExtensionPolicy.getByID(addonId); + const pageURL = policy.extension.baseURI.resolve( + "_generated_background_page.html" + ); + const page = await ExtensionTestUtils.loadContentPage(pageURL); + const { id, installType } = await page.spawn([], async () => { + const res = await this.content.wrappedJSObject.browser.management.getSelf(); + return { id: res.id, installType: res.installType }; + }); + await page.close(); + Assert.equal(id, addonId, "Got results for the expected addon id"); + Assert.equal( + installType, + expectedInstallType, + "Got the expected installType on policy installed extension" + ); +} + add_setup(async function setup() { await AddonTestUtils.promiseStartupManager(); @@ -115,7 +147,14 @@ add_task(async function test_addon_allowed() { ); await install.install(); notEqual(install.addon, null, "Addon should not be null"); + await assertManagementAPIInstallType(install.addon.id, "normal"); equal(install.addon.appDisabled, false, "Addon should not be disabled"); + equal( + install.addon.isInstalledByEnterprisePolicy, + false, + "Addon should NOT be marked as installed by enterprise policy" + ); + await install.addon.uninstall(); }); @@ -169,6 +208,8 @@ add_task(async function test_addon_forceinstalled() { 0, "Addon should not be able to be disabled." ); + await assertManagementAPIInstallType(addon.id, "admin"); + await addon.uninstall(); }); @@ -199,6 +240,8 @@ add_task(async function test_addon_normalinstalled() { 0, "Addon should be able to be disabled." ); + await assertManagementAPIInstallType(addon.id, "admin"); + await addon.uninstall(); }); @@ -290,6 +333,7 @@ add_task(async function test_addon_normalinstalled_file() { 0, "Addon should be able to be disabled." ); + await assertManagementAPIInstallType(addon.id, "admin"); await addon.uninstall(); }); diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_permissions.js b/browser/components/enterprisepolicies/tests/xpcshell/test_permissions.js index f4440e53f5..c9231132c8 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_permissions.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_permissions.js @@ -315,7 +315,7 @@ add_task(async function test_cookie_allow_session() { ); }); -// This again seems out of places, but AutoLaunchProtocolsFromOrigins +// This again seems out of place, but AutoLaunchProtocolsFromOrigins // is all permissions. add_task(async function test_autolaunchprotocolsfromorigins() { await setupPolicyEngineWithJson({ @@ -337,7 +337,7 @@ add_task(async function test_autolaunchprotocolsfromorigins() { ); }); -// This again seems out of places, but PasswordManagerExceptions +// This again seems out of place, but PasswordManagerExceptions // is all permissions. add_task(async function test_passwordmanagerexceptions() { await setupPolicyEngineWithJson({ @@ -353,3 +353,20 @@ add_task(async function test_passwordmanagerexceptions() { Ci.nsIPermissionManager.DENY_ACTION ); }); + +// This again seems out of place, but HttpAllowlist +// is all permissions. +add_task(async function test_httpsonly_exceptions() { + await setupPolicyEngineWithJson({ + policies: { + HttpAllowlist: ["https://http.example.com"], + }, + }); + equal( + PermissionTestUtils.testPermission( + URI("https://http.example.com"), + "https-only-load-insecure" + ), + Ci.nsIPermissionManager.ALLOW_ACTION + ); +}); 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 c0952d0627..cfcb655777 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js @@ -657,13 +657,11 @@ const POLICIES_TESTS = [ { policies: { UserMessaging: { - WhatsNew: false, SkipOnboarding: true, Locked: true, }, }, lockedPrefs: { - "browser.messaging-system.whatsNewPanel.enabled": false, "browser.aboutwelcome.enabled": false, }, }, @@ -1099,6 +1097,55 @@ const POLICIES_TESTS = [ "network.proxy.type": 5, }, }, + + // POLICY: DisableEncryptedClientHello + { + policies: { + DisableEncryptedClientHello: true, + }, + lockedPrefs: { + "network.dns.echconfig.enabled": false, + "network.dns.http3_echconfig.enabled": false, + }, + }, + + // POLICY: PostQuantumKeyAgreementEnabled + { + policies: { + PostQuantumKeyAgreementEnabled: false, + }, + lockedPrefs: { + "security.tls.enable_kyber": false, + }, + }, + + // POLICY: HttpsOnlyMode + { + policies: { + HttpsOnlyMode: "enabled", + }, + unlockedPrefs: { + "dom.security.https_only_mode": true, + }, + }, + + { + policies: { + HttpsOnlyMode: "disallowed", + }, + lockedPrefs: { + "dom.security.https_only_mode": false, + }, + }, + + { + policies: { + HttpsOnlyMode: "force_enabled", + }, + lockedPrefs: { + "dom.security.https_only_mode": true, + }, + }, ]; add_task(async function test_policy_simple_prefs() { diff --git a/browser/components/extensions/ExtensionControlledPopup.sys.mjs b/browser/components/extensions/ExtensionControlledPopup.sys.mjs index b07a8214f3..2d9a9fb584 100644 --- a/browser/components/extensions/ExtensionControlledPopup.sys.mjs +++ b/browser/components/extensions/ExtensionControlledPopup.sys.mjs @@ -235,8 +235,6 @@ export class ExtensionControlledPopup { return; } - win.ownerGlobal.ensureCustomElements("moz-support-link"); - // Find the elements we need. let doc = win.document; let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc); diff --git a/browser/components/extensions/extension.css b/browser/components/extensions/extension.css index f05e2f12a1..431f0148ae 100644 --- a/browser/components/extensions/extension.css +++ b/browser/components/extensions/extension.css @@ -21,7 +21,7 @@ body { background: transparent; box-sizing: border-box; - color: #222426; + color: light-dark(#222426, CanvasText); cursor: default; display: flex; flex-direction: column; diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js index d2f72d4f46..e7a516dcd3 100644 --- a/browser/components/extensions/parent/ext-browser.js +++ b/browser/components/extensions/parent/ext-browser.js @@ -82,7 +82,7 @@ global.openOptionsPage = extension => { extension.id )}/preferences`; - return window.BrowserOpenAddonsMgr(viewId); + return window.BrowserAddonUI.openAddonsMgr(viewId); }; global.makeWidgetId = id => { @@ -709,6 +709,23 @@ class TabTracker extends TabTrackerBase { }; } + getBrowserDataForContext(context) { + if (["tab", "background"].includes(context.viewType)) { + return this.getBrowserData(context.xulBrowser); + } else if (["popup", "sidebar"].includes(context.viewType)) { + // popups and sidebars are nested inside a browser element + // (with url "chrome://browser/content/webext-panels.xhtml") + // and so we look for the corresponding topChromeWindow to + // determine the windowId the panel belongs to. + const chromeWindow = + context.xulBrowser?.ownerGlobal?.browsingContext?.topChromeWindow; + const windowId = chromeWindow ? windowTracker.getId(chromeWindow) : -1; + return { tabId: -1, windowId }; + } + + return { tabId: -1, windowId: -1 }; + } + get activeTab() { let window = windowTracker.topWindow; if (window && window.gBrowser) { diff --git a/browser/components/extensions/parent/ext-menus.js b/browser/components/extensions/parent/ext-menus.js index a5b27bff7d..e4ad9f4747 100644 --- a/browser/components/extensions/parent/ext-menus.js +++ b/browser/components/extensions/parent/ext-menus.js @@ -1100,11 +1100,11 @@ const menuTracker = { ); sidebarHeader.addEventListener("SidebarShown", menuTracker.onSidebarShown); - await window.SidebarUI.promiseInitialized; + await window.SidebarController.promiseInitialized; if ( !window.closed && - window.SidebarUI.currentID === "viewBookmarksSidebar" + window.SidebarController.currentID === "viewBookmarksSidebar" ) { menuTracker.onSidebarShown({ currentTarget: sidebarHeader }); } @@ -1121,8 +1121,8 @@ const menuTracker = { ); sidebarHeader.removeEventListener("SidebarShown", this.onSidebarShown); - if (window.SidebarUI.currentID === "viewBookmarksSidebar") { - let sidebarBrowser = window.SidebarUI.browser; + if (window.SidebarController.currentID === "viewBookmarksSidebar") { + let sidebarBrowser = window.SidebarController.browser; sidebarBrowser.removeEventListener("load", this.onSidebarShown); const menu = sidebarBrowser.contentDocument.getElementById("placesContext"); @@ -1134,10 +1134,10 @@ const menuTracker = { // The event target is an element in a browser window, so |window| will be // the browser window that contains the sidebar. const window = event.currentTarget.ownerGlobal; - if (window.SidebarUI.currentID === "viewBookmarksSidebar") { - let sidebarBrowser = window.SidebarUI.browser; + if (window.SidebarController.currentID === "viewBookmarksSidebar") { + let sidebarBrowser = window.SidebarController.browser; if (sidebarBrowser.contentDocument.readyState !== "complete") { - // SidebarUI.currentID may be updated before the bookmark sidebar's + // SidebarController.currentID may be updated before the bookmark sidebar's // document has finished loading. This sometimes happens when the // sidebar is automatically shown when a new window is opened. sidebarBrowser.addEventListener("load", menuTracker.onSidebarShown, { diff --git a/browser/components/extensions/parent/ext-sidebarAction.js b/browser/components/extensions/parent/ext-sidebarAction.js index 197456abd9..b2c009014e 100644 --- a/browser/components/extensions/parent/ext-sidebarAction.js +++ b/browser/components/extensions/parent/ext-sidebarAction.js @@ -17,8 +17,6 @@ var { IconDetails } = ExtensionParent; // WeakMap[Extension -> SidebarAction] let sidebarActionMap = new WeakMap(); -const sidebarURL = "chrome://browser/content/webext-panels.xhtml"; - /** * Responsible for the sidebar_action section of the manifest as well * as the associated sidebar browser. @@ -40,7 +38,6 @@ this.sidebarAction = class extends ExtensionAPI { let widgetId = makeWidgetId(extension.id); this.id = `${widgetId}-sidebar-action`; this.menuId = `menubar_menu_${this.id}`; - this.switcherMenuId = `sidebarswitcher_menu_${this.id}`; this.browserStyle = options.browser_style; @@ -66,23 +63,6 @@ this.sidebarAction = class extends ExtensionAPI { }; windowTracker.addOpenListener(this.windowOpenListener); - this.updateHeader = event => { - let window = event.target.ownerGlobal; - let details = this.tabContext.get(window.gBrowser.selectedTab); - let header = window.document.getElementById("sidebar-switcher-target"); - if (window.SidebarUI.currentID === this.id) { - this.setMenuIcon(header, details); - } - }; - - this.windowCloseListener = window => { - let header = window.document.getElementById("sidebar-switcher-target"); - if (header) { - header.removeEventListener("SidebarShown", this.updateHeader); - } - }; - windowTracker.addCloseListener(this.windowCloseListener); - sidebarActionMap.set(extension, this); } @@ -91,7 +71,7 @@ this.sidebarAction = class extends ExtensionAPI { } onShutdown(isAppShutdown) { - sidebarActionMap.delete(this.this); + sidebarActionMap.delete(this.extension); this.tabContext.shutdown(); @@ -102,26 +82,18 @@ this.sidebarAction = class extends ExtensionAPI { } for (let window of windowTracker.browserWindows()) { - let { document, SidebarUI } = window; - if (SidebarUI.currentID === this.id) { - SidebarUI.hide(); - } - document.getElementById(this.menuId)?.remove(); - document.getElementById(this.switcherMenuId)?.remove(); - let header = document.getElementById("sidebar-switcher-target"); - header.removeEventListener("SidebarShown", this.updateHeader); - SidebarUI.sidebars.delete(this.id); + let { SidebarController } = window; + SidebarController.removeExtension(this.id); } windowTracker.removeOpenListener(this.windowOpenListener); - windowTracker.removeCloseListener(this.windowCloseListener); } static onUninstall(id) { const sidebarId = `${makeWidgetId(id)}-sidebar-action`; for (let window of windowTracker.browserWindows()) { - let { SidebarUI } = window; - if (SidebarUI.lastOpenedId === sidebarId) { - SidebarUI.lastOpenedId = null; + let { SidebarController } = window; + if (SidebarController.lastOpenedId === sidebarId) { + SidebarController.lastOpenedId = null; } } } @@ -135,12 +107,12 @@ this.sidebarAction = class extends ExtensionAPI { let install = this.extension.startupReason === "ADDON_INSTALL"; for (let window of windowTracker.browserWindows()) { this.updateWindow(window); - let { SidebarUI } = window; + let { SidebarController } = window; if ( (install && this.extension.manifest.sidebar_action.open_at_install) || - SidebarUI.lastOpenedId == this.id + SidebarController.lastOpenedId == this.id ) { - SidebarUI.show(this.id); + SidebarController.show(this.id); } } } @@ -149,60 +121,29 @@ this.sidebarAction = class extends ExtensionAPI { if (!this.extension.canAccessWindow(window)) { return; } - let { document, SidebarUI } = window; - let keyId = `ext-key-id-${this.id}`; - - SidebarUI.sidebars.set(this.id, { - title: details.title, - url: sidebarURL, + this.panel = details.panel; + let { SidebarController } = window; + SidebarController.registerExtension(this.id, { + icon: this.getMenuIcon(details), menuId: this.menuId, - switcherMenuId: this.switcherMenuId, - // The following properties are specific to extensions + title: details.title, extensionId: this.extension.id, - panel: details.panel, - browserStyle: this.browserStyle, + onload: () => + SidebarController.browser.contentWindow.loadPanel( + this.extension.id, + this.panel, + this.browserStyle + ), }); - - let header = document.getElementById("sidebar-switcher-target"); - header.addEventListener("SidebarShown", this.updateHeader); - - // Insert a menuitem for View->Show Sidebars. - let menuitem = document.createXULElement("menuitem"); - menuitem.setAttribute("id", this.menuId); - menuitem.setAttribute("type", "checkbox"); - menuitem.setAttribute("label", details.title); - menuitem.setAttribute("oncommand", `SidebarUI.toggle("${this.id}");`); - menuitem.setAttribute("class", "menuitem-iconic webextension-menuitem"); - menuitem.setAttribute("key", keyId); - this.setMenuIcon(menuitem, details); - - // Insert a toolbarbutton for the sidebar dropdown selector. - let switcherMenuitem = menuitem.cloneNode(); - switcherMenuitem.setAttribute("id", this.switcherMenuId); - switcherMenuitem.removeAttribute("type"); - - document.getElementById("viewSidebarMenu").appendChild(menuitem); - let separator = document.getElementById("sidebar-extensions-separator"); - separator.parentNode.insertBefore(switcherMenuitem, separator); - - return menuitem; } - setMenuIcon(menuitem, details) { + getMenuIcon(details) { let getIcon = size => IconDetails.escapeUrl( IconDetails.getPreferredIcon(details.icon, this.extension, size).icon ); - menuitem.setAttribute( - "style", - ` - --webextension-menuitem-image: image-set( - url("${getIcon(16)}"), - url("${getIcon(32)}") 2x - ); - ` - ); + return `image-set(url("${getIcon(16)}"), url("${getIcon(32)}") 2x)`; } /** @@ -214,34 +155,26 @@ this.sidebarAction = class extends ExtensionAPI { * Tab specific sidebar configuration. */ updateButton(window, tabData) { - let { document, SidebarUI } = window; + let { document, SidebarController } = window; let title = tabData.title || this.extension.name; - let menu = document.getElementById(this.menuId); - if (!menu) { - menu = this.createMenuItem(window, tabData); + if (!document.getElementById(this.menuId)) { + // Menu items are added when new windows are opened, or from onReady (when + // an extension has fully started). The menu item may be missing at this + // point if the extension updates the sidebar during its startup. + this.createMenuItem(window, tabData); } - - let urlChanged = tabData.panel !== SidebarUI.sidebars.get(this.id).panel; + let urlChanged = tabData.panel !== this.panel; if (urlChanged) { - SidebarUI.sidebars.get(this.id).panel = tabData.panel; - } - - menu.setAttribute("label", title); - this.setMenuIcon(menu, tabData); - - let button = document.getElementById(this.switcherMenuId); - button.setAttribute("label", title); - this.setMenuIcon(button, tabData); - - // Update the sidebar if this extension is the current sidebar. - if (SidebarUI.currentID === this.id) { - SidebarUI.title = title; - let header = document.getElementById("sidebar-switcher-target"); - this.setMenuIcon(header, tabData); - if (SidebarUI.isOpen && urlChanged) { - SidebarUI.show(this.id); - } + this.panel = tabData.panel; } + SidebarController.setExtensionAttributes( + this.id, + { + icon: this.getMenuIcon(tabData), + label: title, + }, + urlChanged + ); } /** @@ -382,9 +315,9 @@ this.sidebarAction = class extends ExtensionAPI { * @param {ChromeWindow} window */ triggerAction(window) { - let { SidebarUI } = window; - if (SidebarUI && this.extension.canAccessWindow(window)) { - SidebarUI.toggle(this.id); + let { SidebarController } = window; + if (SidebarController && this.extension.canAccessWindow(window)) { + SidebarController.toggle(this.id); } } @@ -394,9 +327,9 @@ this.sidebarAction = class extends ExtensionAPI { * @param {ChromeWindow} window */ open(window) { - let { SidebarUI } = window; - if (SidebarUI && this.extension.canAccessWindow(window)) { - SidebarUI.show(this.id); + let { SidebarController } = window; + if (SidebarController && this.extension.canAccessWindow(window)) { + SidebarController.show(this.id); } } @@ -407,7 +340,7 @@ this.sidebarAction = class extends ExtensionAPI { */ close(window) { if (this.isOpen(window)) { - window.SidebarUI.hide(); + window.SidebarController.hide(); } } @@ -417,15 +350,15 @@ this.sidebarAction = class extends ExtensionAPI { * @param {ChromeWindow} window */ toggle(window) { - let { SidebarUI } = window; - if (!SidebarUI || !this.extension.canAccessWindow(window)) { + let { SidebarController } = window; + if (!SidebarController || !this.extension.canAccessWindow(window)) { return; } if (!this.isOpen(window)) { - SidebarUI.show(this.id); + SidebarController.show(this.id); } else { - SidebarUI.hide(); + SidebarController.hide(); } } @@ -436,8 +369,8 @@ this.sidebarAction = class extends ExtensionAPI { * @returns {boolean} */ isOpen(window) { - let { SidebarUI } = window; - return SidebarUI.isOpen && this.id == SidebarUI.currentID; + let { SidebarController } = window; + return SidebarController.isOpen && this.id == SidebarController.currentID; } getAPI(context) { diff --git a/browser/components/extensions/test/browser/browser.toml b/browser/components/extensions/test/browser/browser.toml index 570a51eeb4..b5b4322ffe 100644 --- a/browser/components/extensions/test/browser/browser.toml +++ b/browser/components/extensions/test/browser/browser.toml @@ -392,6 +392,8 @@ run-if = ["crashreporter"] ["browser_ext_request_permissions.js"] +["browser_ext_runtime_getContexts.js"] + ["browser_ext_runtime_onPerformanceWarning.js"] ["browser_ext_runtime_openOptionsPage.js"] 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 26d1536de1..32de8b95f2 100644 --- a/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js @@ -2,10 +2,6 @@ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; -ChromeUtils.defineESModuleGetters(this, { - AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", -}); - XPCOMUtils.defineLazyPreferenceGetter( this, "ABUSE_REPORT_ENABLED", @@ -546,35 +542,6 @@ async function browseraction_contextmenu_report_extension_helper() { useAddonManager: "temporary", }); - async function testReportDialog(viaUnifiedContextMenu) { - const reportDialogWindow = await BrowserTestUtils.waitForCondition( - () => AbuseReporter.getOpenDialog(), - "Wait for the abuse report dialog to have been opened" - ); - - const reportDialogParams = reportDialogWindow.arguments[0].wrappedJSObject; - is( - reportDialogParams.report.addon.id, - id, - "Abuse report dialog has the expected addon id" - ); - is( - reportDialogParams.report.reportEntryPoint, - viaUnifiedContextMenu ? "unified_context_menu" : "toolbar_context_menu", - "Abuse report dialog has the expected reportEntryPoint" - ); - - info("Wait the report dialog to complete rendering"); - await reportDialogParams.promiseReportPanel; - info("Close the report dialog"); - reportDialogWindow.close(); - is( - await reportDialogParams.promiseReport, - undefined, - "Report resolved as user cancelled when the window is closed" - ); - } - async function testContextMenu(menuId, customizing) { info(`Open browserAction context menu in ${menuId}`); let menu = await openContextMenu(menuId, buttonId); @@ -591,54 +558,27 @@ async function browseraction_contextmenu_report_extension_helper() { let aboutAddonsBrowser; - if (AbuseReporter.amoFormEnabled) { - const reportURL = Services.urlFormatter - .formatURLPref("extensions.abuseReport.amoFormURL") - .replace("%addonID%", id); - - const promiseReportTab = BrowserTestUtils.waitForNewTab( - gBrowser, - reportURL, - /* waitForLoad */ false, - // Expect it to be the next tab opened - /* waitForAnyTab */ false - ); - await closeChromeContextMenu(menuId, reportExtension); - const reportTab = await promiseReportTab; - // Remove the report tab and expect the selected tab - // to become the about:addons tab. - BrowserTestUtils.removeTab(reportTab); - is( - gBrowser.selectedBrowser.currentURI.spec, - "about:blank", - "Expect about:addons tab to not have been opened (amoFormEnabled=true)" - ); - } else { - // When running in customizing mode "about:addons" will load in a new tab, - // otherwise it will replace the existing blank tab. - const onceAboutAddonsTab = customizing - ? BrowserTestUtils.waitForNewTab(gBrowser, "about:addons") - : BrowserTestUtils.waitForCondition(() => { - return gBrowser.currentURI.spec === "about:addons"; - }, "Wait an about:addons tab to be opened"); - await closeChromeContextMenu(menuId, reportExtension); - await onceAboutAddonsTab; - const browser = gBrowser.selectedBrowser; - is( - browser.currentURI.spec, - "about:addons", - "Got about:addons tab selected (amoFormEnabled=false)" - ); - // Do not wait for the about:addons tab to be loaded if its - // document is already readyState==complete. - // This prevents intermittent timeout failures while running - // this test in optimized builds. - if (browser.contentDocument?.readyState != "complete") { - await BrowserTestUtils.browserLoaded(browser); - } - await testReportDialog(usingUnifiedContextMenu); - aboutAddonsBrowser = browser; - } + const reportURL = Services.urlFormatter + .formatURLPref("extensions.abuseReport.amoFormURL") + .replace("%addonID%", id); + + const promiseReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + reportURL, + /* waitForLoad */ false, + // Expect it to be the next tab opened + /* waitForAnyTab */ false + ); + await closeChromeContextMenu(menuId, reportExtension); + const reportTab = await promiseReportTab; + // Remove the report tab and expect the selected tab + // to become the about:addons tab. + BrowserTestUtils.removeTab(reportTab); + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:blank", + "Expect about:addons tab to not have been opened" + ); // Close the new about:addons tab when running in customize mode, // or cancel the abuse report if the about:addons page has been @@ -729,22 +669,7 @@ add_task(async function test_unified_extensions_ui() { await browseraction_contextmenu_manage_extension_helper(); await browseraction_contextmenu_remove_extension_helper(); await test_no_toolbar_pinning_on_builtin_helper(); -}); - -add_task(async function test_report_amoFormEnabled() { - await SpecialPowers.pushPrefEnv({ - set: [["extensions.abuseReport.amoFormEnabled", true]], - }); - await browseraction_contextmenu_report_extension_helper(); - await SpecialPowers.popPrefEnv(); -}); - -add_task(async function test_report_amoFormDisabled() { - await SpecialPowers.pushPrefEnv({ - set: [["extensions.abuseReport.amoFormEnabled", false]], - }); await browseraction_contextmenu_report_extension_helper(); - await SpecialPowers.popPrefEnv(); }); /** diff --git a/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js b/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js index 526dfbbeeb..9ca2a9c666 100644 --- a/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js +++ b/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js @@ -2,10 +2,9 @@ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; -add_task(async function testTabSwitchActionContext() { - await SpecialPowers.pushPrefEnv({ - set: [["extensions.manifestV3.enabled", true]], - }); +ChromeUtils.defineESModuleGetters(this, { + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", }); async function testExecuteBrowserActionWithOptions(options = {}) { @@ -192,3 +191,232 @@ add_task( }); } ); + +add_task(async function test_fallback_to_execute_browser_action_in_mv3() { + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + const EXTENSION_ID = "@test-action"; + const extMV2 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + browser_action: {}, + browser_specific_settings: { gecko: { id: EXTENSION_ID } }, + commands: { + _execute_browser_action: { + suggested_key: { + default: "Alt+1", + }, + }, + }, + }, + async background() { + await browser.commands.update({ + name: "_execute_browser_action", + shortcut: "Alt+Shift+2", + }); + browser.test.sendMessage("command-update"); + }, + useAddonManager: "temporary", + }); + await extMV2.startup(); + await extMV2.awaitMessage("command-update"); + + let storedCommands = ExtensionSettingsStore.getAllForExtension( + EXTENSION_ID, + "commands" + ); + Assert.deepEqual( + storedCommands, + ["_execute_browser_action"], + "expected a stored command" + ); + + const extDataMV3 = { + manifest: { + manifest_version: 3, + action: {}, + browser_specific_settings: { gecko: { id: EXTENSION_ID } }, + commands: { + _execute_action: { + suggested_key: { + default: "Alt+3", + }, + }, + }, + }, + background() { + browser.action.onClicked.addListener(() => { + browser.test.notifyPass("execute-action-on-clicked-fired"); + }); + + browser.test.onMessage.addListener(async (msg, data) => { + switch (msg) { + case "verify": { + const commands = await browser.commands.getAll(); + browser.test.assertDeepEq( + data, + commands, + "expected correct commands" + ); + browser.test.sendMessage(`${msg}-done`); + break; + } + + case "update": + await browser.commands.update(data); + browser.test.sendMessage(`${msg}-done`); + break; + + case "reset": + await browser.commands.reset("_execute_action"); + browser.test.sendMessage(`${msg}-done`); + break; + + default: + browser.test.fail(`unexpected message: ${msg}`); + } + }); + + browser.test.sendMessage("ready"); + }, + useAddonManager: "temporary", + }; + const extMV3 = ExtensionTestUtils.loadExtension(extDataMV3); + await extMV3.startup(); + await extMV3.awaitMessage("ready"); + + // We should have the shortcut value from the previous extension. + extMV3.sendMessage("verify", [ + { + name: "_execute_action", + description: null, + shortcut: "Alt+Shift+2", + }, + ]); + await extMV3.awaitMessage("verify-done"); + + // Execute the shortcut from the MV2 extension. + await SimpleTest.promiseFocus(window); + EventUtils.synthesizeKey("2", { + altKey: true, + shiftKey: true, + }); + await extMV3.awaitFinish("execute-action-on-clicked-fired"); + + // Update the shortcut. + extMV3.sendMessage("update", { + name: "_execute_action", + shortcut: "Alt+Shift+4", + }); + await extMV3.awaitMessage("update-done"); + + extMV3.sendMessage("verify", [ + { + name: "_execute_action", + description: null, + shortcut: "Alt+Shift+4", + }, + ]); + await extMV3.awaitMessage("verify-done"); + + // At this point, we should have the old and new commands in storage. + storedCommands = ExtensionSettingsStore.getAllForExtension( + EXTENSION_ID, + "commands" + ); + Assert.deepEqual( + storedCommands, + ["_execute_browser_action", "_execute_action"], + "expected two stored commands" + ); + + // Disarm any pending writes before we modify the JSONFile directly. + await ExtensionSettingsStore._reloadFile( + true // saveChanges + ); + + let jsonFileName = "extension-settings.json"; + let storePath = PathUtils.join(PathUtils.profileDir, jsonFileName); + + let settingsStoreData = await IOUtils.readJSON(storePath); + Assert.deepEqual( + Array.from(Object.keys(settingsStoreData.commands)), + ["_execute_browser_action", "_execute_action"], + "expected command hortcuts data to be found in extension-settings.json" + ); + + // Reverse the order of _execute_action and _execute_browser_action stored + // in the settings store. + settingsStoreData.commands = { + _execute_action: settingsStoreData.commands._execute_action, + _execute_browser_action: settingsStoreData.commands._execute_browser_action, + }; + + Assert.deepEqual( + Array.from(Object.keys(settingsStoreData.commands)), + ["_execute_action", "_execute_browser_action"], + "expected command shortcuts order to be reversed in extension-settings.json data" + ); + + // Write the extension-settings.json data and reload it. + await IOUtils.writeJSON(storePath, settingsStoreData); + await ExtensionSettingsStore._reloadFile( + false // saveChanges + ); + + // Restart the extension to verify that the loaded command is the right one. + const updatedExtMV3 = ExtensionTestUtils.loadExtension(extDataMV3); + await updatedExtMV3.startup(); + await updatedExtMV3.awaitMessage("ready"); + + // We should *still* have two stored commands. + storedCommands = ExtensionSettingsStore.getAllForExtension( + EXTENSION_ID, + "commands" + ); + Assert.deepEqual( + storedCommands, + ["_execute_action", "_execute_browser_action"], + "expected two stored commands" + ); + + updatedExtMV3.sendMessage("verify", [ + { + name: "_execute_action", + description: null, + shortcut: "Alt+Shift+4", + }, + ]); + await updatedExtMV3.awaitMessage("verify-done"); + + updatedExtMV3.sendMessage("reset"); + await updatedExtMV3.awaitMessage("reset-done"); + + // Resetting the shortcut should take the default value from the latest + // extension version. + updatedExtMV3.sendMessage("verify", [ + { + name: "_execute_action", + description: null, + shortcut: "Alt+3", + }, + ]); + await updatedExtMV3.awaitMessage("verify-done"); + + // At this point, we should no longer have any stored commands, since we are + // using the default. + storedCommands = ExtensionSettingsStore.getAllForExtension( + EXTENSION_ID, + "commands" + ); + Assert.deepEqual(storedCommands, [], "expected no stored command"); + + await extMV2.unload(); + await extMV3.unload(); + await updatedExtMV3.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_update.js b/browser/components/extensions/test/browser/browser_ext_commands_update.js index 5e1f05e346..9598328d26 100644 --- a/browser/components/extensions/test/browser/browser_ext_commands_update.js +++ b/browser/components/extensions/test/browser/browser_ext_commands_update.js @@ -401,11 +401,11 @@ add_task(async function updateSidebarCommand() { await extension.awaitMessage("sidebar"); // Show and hide the switcher panel to generate the initial shortcuts. - let switcherShown = promisePopupShown(SidebarUI._switcherPanel); - SidebarUI.showSwitcherPanel(); + let switcherShown = promisePopupShown(SidebarController._switcherPanel); + SidebarController.showSwitcherPanel(); await switcherShown; - let switcherHidden = promisePopupHidden(SidebarUI._switcherPanel); - SidebarUI.hideSwitcherPanel(); + let switcherHidden = promisePopupHidden(SidebarController._switcherPanel); + SidebarController.hideSwitcherPanel(); await switcherHidden; let menuitemId = `sidebarswitcher_menu_${makeWidgetId( diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus.js b/browser/components/extensions/test/browser/browser_ext_contextMenus.js index 77e4bf0827..0ed5d6ce9e 100644 --- a/browser/components/extensions/test/browser/browser_ext_contextMenus.js +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus.js @@ -724,7 +724,7 @@ add_task(async function test_bookmark_sidebar_contextmenu() { await extension.startup(); let bookmarkGuid = await extension.awaitMessage("bookmark-created"); - let sidebar = window.SidebarUI.browser; + let sidebar = window.SidebarController.browser; let menu = sidebar.contentDocument.getElementById("placesContext"); tree.selectItems([bookmarkGuid]); let shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js index 24411731f7..3c0a1269a9 100644 --- a/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js @@ -54,7 +54,7 @@ add_task(async function test_bookmark_sidebar_contextmenu() { expectedVirtualID, ] of expected_bookmarkID_2_virtualID) { info(`Testing context menu for Bookmark ID "${expectedBookmarkID}"`); - let sidebar = window.SidebarUI.browser; + let sidebar = window.SidebarController.browser; let menu = sidebar.contentDocument.getElementById("placesContext"); tree.selectItems([expectedBookmarkID]); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js index 0f353600d7..ae1ec31827 100644 --- a/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js @@ -221,7 +221,7 @@ add_task(async function privileged_are_allowed_to_use_restrictedSchemes() { manifest: { permissions: ["tabs", "contextMenus", "mozillaAddons"], }, - async background() { + background() { browser.contextMenus.create({ id: "privileged-extension", title: "Privileged Extension", @@ -229,6 +229,7 @@ add_task(async function privileged_are_allowed_to_use_restrictedSchemes() { documentUrlPatterns: ["about:reader*"], }); + let articleReady = Promise.withResolvers(); browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if ( changeInfo.status === "complete" && @@ -236,6 +237,9 @@ add_task(async function privileged_are_allowed_to_use_restrictedSchemes() { ) { browser.test.sendMessage("readerModeEntered"); } + if (tab.isArticle && tab.url.includes("/readerModeArticle.html")) { + articleReady.resolve(); + } }); browser.test.onMessage.addListener(async msg => { @@ -244,8 +248,20 @@ add_task(async function privileged_are_allowed_to_use_restrictedSchemes() { return; } + browser.test.log("Waiting for tab.isArticle to be true"); + await articleReady.promise; + browser.test.log("Toggling reader mode"); browser.tabs.toggleReaderMode(); }); + + browser.tabs.query( + { url: "*://example.com/*/readerModeArticle.html" }, + tabs => { + if (tabs[0].isArticle) { + articleReady.resolve(); + } + } + ); }, }); diff --git a/browser/components/extensions/test/browser/browser_ext_getViews.js b/browser/components/extensions/test/browser/browser_ext_getViews.js index 1af190e753..20db9c86c8 100644 --- a/browser/components/extensions/test/browser/browser_ext_getViews.js +++ b/browser/components/extensions/test/browser/browser_ext_getViews.js @@ -78,44 +78,6 @@ function genericChecker() { browser.test.sendMessage(kind + "-ready"); } -async function promiseBrowserContentUnloaded(browser) { - // Wait until the content has unloaded before resuming the test, to avoid - // calling extension.getViews too early (and having intermittent failures). - const MSG_WINDOW_DESTROYED = "Test:BrowserContentDestroyed"; - let unloadPromise = new Promise(resolve => { - Services.ppmm.addMessageListener(MSG_WINDOW_DESTROYED, function listener() { - Services.ppmm.removeMessageListener(MSG_WINDOW_DESTROYED, listener); - resolve(); - }); - }); - - await ContentTask.spawn( - browser, - MSG_WINDOW_DESTROYED, - MSG_WINDOW_DESTROYED => { - let innerWindowId = this.content.windowGlobalChild.innerWindowId; - let observer = subject => { - if ( - innerWindowId === subject.QueryInterface(Ci.nsISupportsPRUint64).data - ) { - Services.obs.removeObserver(observer, "inner-window-destroyed"); - - // Use process message manager to ensure that the message is delivered - // even after the <browser>'s message manager is disconnected. - Services.cpmm.sendAsyncMessage(MSG_WINDOW_DESTROYED); - } - }; - // Observe inner-window-destroyed, like ExtensionPageChild, to ensure that - // the ExtensionPageContextChild instance has been unloaded when we resolve - // the unloadPromise. - Services.obs.addObserver(observer, "inner-window-destroyed"); - } - ); - - // Return an object so that callers can use "await". - return { unloadPromise }; -} - add_task(async function () { let win1 = await BrowserTestUtils.openNewBrowserWindow(); let win2 = await BrowserTestUtils.openNewBrowserWindow(); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js index c9627c5ae9..7960dd74dc 100644 --- a/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js +++ b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js @@ -189,7 +189,9 @@ add_task(async function overrideContext_permissions() { // permissions.request requires user input, export helper. await SpecialPowers.spawn( - SidebarUI.browser.contentDocument.getElementById("webext-panels-browser"), + SidebarController.browser.contentDocument.getElementById( + "webext-panels-browser" + ), [], () => { const { ExtensionCommon } = ChromeUtils.importESModule( @@ -212,7 +214,9 @@ add_task(async function overrideContext_permissions() { await BrowserTestUtils.synthesizeMouseAtCenter( "a", { type: "contextmenu" }, - SidebarUI.browser.contentDocument.getElementById("webext-panels-browser") + SidebarController.browser.contentDocument.getElementById( + "webext-panels-browser" + ) ); } while (await extension.awaitMessage("continue_test")); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js b/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js index 4fc9ebba82..9035b6eecb 100644 --- a/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js +++ b/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js @@ -308,6 +308,14 @@ add_task(async function independentMenusInDifferentTabs() { gBrowser.selectedTab = tab2; let targetElementId2 = await extension.openAndCloseMenu("#editabletext"); + if (targetElementId === targetElementId2) { + // targetElementId is only guaranteed to be unique within a tab, so odds are + // that by unlucky coincidence, that the discovered ID accidentally overlaps + // with the actual ID. + info(`Got same targetElementId ${targetElementId}, retrying for a new one`); + targetElementId2 = await extension.openAndCloseMenu("#editabletext"); + } + Assert.notEqual(targetElementId, targetElementId2, "targetElementId differ"); await extension.checkIsValid( targetElementId2, diff --git a/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js b/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js index 9c5f447a76..1c9224ca00 100644 --- a/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js +++ b/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js @@ -120,7 +120,7 @@ async function test_mousewheel_zoom(test) { const sidebar = document.getElementById("sidebar-box"); ok(!sidebar.hidden, "Sidebar box is visible"); - browser = SidebarUI.browser.contentWindow.gBrowser.selectedBrowser; + browser = SidebarController.browser.contentWindow.gBrowser.selectedBrowser; } else if (test == TESTS.BROWSER_ACTION) { browser = await openBrowserActionPanel(extension, undefined, true); } else if (test == TESTS.PAGE_ACTION) { diff --git a/browser/components/extensions/test/browser/browser_ext_openPanel.js b/browser/components/extensions/test/browser/browser_ext_openPanel.js index ed96bf2520..c56c0a31b6 100644 --- a/browser/components/extensions/test/browser/browser_ext_openPanel.js +++ b/browser/components/extensions/test/browser/browser_ext_openPanel.js @@ -136,16 +136,16 @@ add_task(async function test_openPopup_requires_user_interaction() { {}, gBrowser.selectedBrowser ); - await TestUtils.waitForCondition(() => !SidebarUI.isOpen); + await TestUtils.waitForCondition(() => !SidebarController.isOpen); await click("#toggleSidebarAction"); - await TestUtils.waitForCondition(() => SidebarUI.isOpen); + await TestUtils.waitForCondition(() => SidebarController.isOpen); await BrowserTestUtils.synthesizeMouseAtCenter( "#toggleSidebarAction", {}, gBrowser.selectedBrowser ); - await TestUtils.waitForCondition(() => !SidebarUI.isOpen); + await TestUtils.waitForCondition(() => !SidebarController.isOpen); BrowserTestUtils.removeTab(gBrowser.selectedTab); await extension.unload(); diff --git a/browser/components/extensions/test/browser/browser_ext_originControls.js b/browser/components/extensions/test/browser/browser_ext_originControls.js index 176eef08bc..6bcae3ef8f 100644 --- a/browser/components/extensions/test/browser/browser_ext_originControls.js +++ b/browser/components/extensions/test/browser/browser_ext_originControls.js @@ -20,6 +20,12 @@ const l10n = new Localization( true ); +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.originControls.grantByDefault", false]], + }); +}); + async function makeExtension({ useAddonManager = "temporary", manifest_version = 3, diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js b/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js index fd589acdbd..71f13a4691 100644 --- a/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js @@ -268,10 +268,14 @@ add_task(async function test_pageAction_restrictScheme_false() { }, }, background: function () { - browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { + let articleReady = Promise.withResolvers(); + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (changeInfo.url && changeInfo.url.startsWith("about:reader")) { browser.test.sendMessage("readerModeEntered"); } + if (tab.isArticle && tab.url.includes("/readerModeArticle.html")) { + articleReady.resolve(); + } }); browser.test.onMessage.addListener(async msg => { @@ -280,8 +284,20 @@ add_task(async function test_pageAction_restrictScheme_false() { return; } + browser.test.log("Waiting for tab.isArticle to be true"); + await articleReady.promise; + browser.test.log("Toggling reader mode"); browser.tabs.toggleReaderMode(); }); + + browser.tabs.query( + { url: "*://example.com/*/readerModeArticle.html" }, + tabs => { + if (tabs[0].isArticle) { + articleReady.resolve(); + } + } + ); }, }); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js b/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js index fa2c414047..bdc5145dc5 100644 --- a/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js +++ b/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js @@ -70,6 +70,8 @@ add_task(async function testPopupSelectPopup() { }); const selectRect = await SpecialPowers.spawn(iframe, [], async () => { + await SpecialPowers.contentTransformsReceived(content.window); + await ContentTaskUtils.waitForCondition(() => { return content.document.querySelector("select"); }); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_getContexts.js b/browser/components/extensions/test/browser/browser_ext_runtime_getContexts.js new file mode 100644 index 0000000000..4d28cd5f87 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_getContexts.js @@ -0,0 +1,597 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_devtools.js"); + +async function genericChecker() { + const params = new URLSearchParams(window.location.search); + const kind = params.get("kind"); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == `${kind}-get-contexts-invalid-params`) { + browser.test.assertThrows( + () => browser.runtime.getContexts({ unknownParamName: true }), + /Type error for parameter filter \(Unexpected property "unknownParamName"\)/, + "Got the expected error on unexpected filter property" + ); + browser.test.sendMessage(`${msg}:done`); + } else if (msg == `${kind}-get-contexts`) { + const filter = args[0]; + try { + const result = await browser.runtime.getContexts(filter); + browser.test.sendMessage(`${msg}:result`, result); + } catch (err) { + // In case of unexpected errors, log a failure and let the test + // to continue to avoid it to only fail after timing out. + browser.test.fail(`browser.runtime.getContexts call rejected: ${err}`); + browser.test.sendMessage(`${msg}:result`, []); + } + } else if (msg == `${kind}-history-push-state`) { + const pushStateURL = args[0]; + window.history.pushState({}, "", pushStateURL); + browser.test.sendMessage(`${msg}:done`); + } else if (msg == `${kind}-create-iframe`) { + const iframeUrl = args[0]; + const iframe = document.createElement("iframe"); + iframe.src = iframeUrl; + document.body.appendChild(iframe); + } else if (msg == `${kind}-open-options-page`) { + browser.runtime.openOptionsPage(); + } + }); + + if (kind === "devtools-page") { + await browser.devtools.panels.create( + "Test DevTool Panel", + "fake-icon.png", + "page.html?kind=devtools-panel" + ); + } + + browser.test.log(`${kind} extension page loaded`); + browser.test.sendMessage(`${kind}-loaded`); +} + +async function triggerActionPopup(extension, win, callback) { + // Window needs focus to open popups. + await focusWindow(win); + await clickBrowserAction(extension, win); + let browser = await awaitExtensionPanel(extension, win); + + await callback(); + + let { unloadPromise } = await promiseBrowserContentUnloaded(browser); + closeBrowserAction(extension, win); + await unloadPromise; +} + +const byWindowId = (a, b) => a.windowId - b.windowId; +const byTabId = (a, b) => a.tabId - b.tabId; +const byFrameId = (a, b) => a.frameId - b.frameId; +const byContextType = (a, b) => a.contextType.localeCompare(b.contextType); + +const assertValidContextId = contextId => { + Assert.equal( + typeof contextId, + "string", + "contextId should be set to a string" + ); + Assert.notEqual( + contextId.length, + 0, + "contextId should be set to a non-zero length string" + ); +}; + +const assertGetContextsResult = ( + actual, + expected, + msg, + { assertContextId = false } = {} +) => { + const actualCopy = assertContextId ? actual : actual.map(it => ({ ...it })); + if (!assertContextId) { + actualCopy.forEach(it => delete it.contextId); + } + for (let [idx, expectedProps] of expected.entries()) { + Assert.deepEqual(actualCopy[idx], expectedProps, msg); + } + Assert.equal( + actualCopy.length, + expected.length, + "Got the expected number of extension contexts" + ); +}; + +add_task(async function test_runtime_getContexts() { + const EXT_ID = "runtime-getContexts@mochitest"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + incognitoOverride: "spanning", + manifest: { + manifest_version: 3, + browser_specific_settings: { gecko: { id: EXT_ID } }, + + action: { + default_popup: "page.html?kind=action", + default_area: "navbar", + }, + + sidebar_action: { + default_panel: "page.html?kind=sidebar", + }, + + options_ui: { + page: "page.html?kind=options", + }, + + devtools_page: "page.html?kind=devtools-page", + + background: { + page: "page.html?kind=background", + }, + }, + + files: { + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": genericChecker, + }, + }); + + const { + Management: { + global: { tabTracker, windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let firstWin = window; + let secondWin = await BrowserTestUtils.openNewBrowserWindow(); + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await extension.startup(); + await extension.awaitMessage("background-loaded"); + + // Expect 3 sidebars (2 non-private and 1 private windows). + await extension.awaitMessage("sidebar-loaded"); + await extension.awaitMessage("sidebar-loaded"); + await extension.awaitMessage("sidebar-loaded"); + + let firstWinId = windowTracker.getId(firstWin); + let secondWinId = windowTracker.getId(secondWin); + let privateWinId = windowTracker.getId(privateWin); + + const getGetContextsResults = async ({ filter, sortBy }) => { + extension.sendMessage("background-get-contexts", filter); + let results = await extension.awaitMessage( + "background-get-contexts:result" + ); + if (sortBy) { + results.sort(sortBy); + } + return results; + }; + + const resolveExtPageUrl = urlPath => + WebExtensionPolicy.getByID(EXT_ID).extension.baseURI.resolve(urlPath); + + const documentOrigin = resolveExtPageUrl("/").slice(0, -1); + + const getExpectedExtensionContext = ({ + contextId, + contextType, + documentUrl, + incognito = false, + frameId = 0, + tabId = -1, + windowId = -1, + }) => { + let props = { + contextType, + documentOrigin, + documentUrl, + incognito, + frameId, + tabId, + windowId, + }; + if (contextId) { + props.contextId = contextId; + } + return props; + }; + + let expected = [ + getExpectedExtensionContext({ + contextType: "BACKGROUND", + documentUrl: resolveExtPageUrl("page.html?kind=background"), + }), + + getExpectedExtensionContext({ + contextType: "SIDE_PANEL", + documentUrl: resolveExtPageUrl("page.html?kind=sidebar"), + windowId: firstWinId, + }), + + getExpectedExtensionContext({ + contextType: "SIDE_PANEL", + documentUrl: resolveExtPageUrl("page.html?kind=sidebar"), + windowId: secondWinId, + }), + + getExpectedExtensionContext({ + contextType: "SIDE_PANEL", + documentUrl: resolveExtPageUrl("page.html?kind=sidebar"), + windowId: privateWinId, + incognito: true, + }), + ].sort(byWindowId); + + info("Test getContexts error on unsupported getContexts filter property"); + extension.sendMessage("background-get-contexts-invalid-params"); + await extension.awaitMessage("background-get-contexts-invalid-params:done"); + + info("Test getContexts with a valid empty filter"); + let actual = await getGetContextsResults({ filter: {}, sortBy: byWindowId }); + + assertGetContextsResult( + actual, + expected, + "Got the expected results from runtime.getContexts (with an empty filter)" + ); + + for (const ctx of actual) { + info(`Validate contextId for context ${ctx.contextType} ${ctx.contextId}`); + assertValidContextId(ctx.contextId); + } + + await BrowserTestUtils.withNewTab( + { + gBrowser: secondWin.gBrowser, + url: resolveExtPageUrl("page.html?kind=tab"), + }, + async browser => { + info("Wait the extension page to be fully loaded in the new tab"); + await extension.awaitMessage("tab-loaded"); + + const tabId = tabTracker.getBrowserData(browser).tabId; + + const expectedTabContext = getExpectedExtensionContext({ + contextType: "TAB", + documentUrl: resolveExtPageUrl("page.html?kind=tab"), + windowId: secondWinId, + tabId, + incognito: false, + }); + + info("Test getContexts with contextTypes TAB filter"); + let actual = await getGetContextsResults({ + filter: { contextTypes: ["TAB"] }, + }); + assertGetContextsResult( + actual, + [expectedTabContext], + "Got the expected results from runtime.getContexts (with contextTypes TAB filter)" + ); + assertValidContextId(actual[0].contextId); + const initialTabContextId = actual[0].contextId; + + info("Test getContexts with contextTypes TabIds filter"); + actual = await getGetContextsResults({ + filter: { tabIds: [tabId] }, + }); + assertGetContextsResult( + actual, + [expectedTabContext], + "Got the expected results from runtime.getContexts (with tabIds filter)" + ); + + info("Test getContexts with contextTypes WindowIds filter"); + actual = await getGetContextsResults({ + filter: { windowIds: [secondWinId] }, + sortBy: byTabId, + }); + assertGetContextsResult( + actual, + [ + expectedTabContext, + expected.find(it => it.windowId === secondWinId), + ].sort(byTabId), + "Got the expected results from runtime.getContexts (with windowIds filter)" + ); + + info("Test getContexts after navigating the tab"); + const newTabURL = resolveExtPageUrl("page.html?kind=tab&navigated=true"); + browser.loadURI(Services.io.newURI(newTabURL), { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + await extension.awaitMessage("tab-loaded"); + + actual = await getGetContextsResults({ + filter: { + contextTypes: ["TAB"], + windowIds: [secondWinId], + }, + }); + Assert.equal(actual.length, 1, "Expect 1 tab extension context"); + Assert.equal( + actual[0].documentUrl, + newTabURL, + "Expect documentUrl to match the new loaded url" + ); + Assert.equal(actual[0].frameId, 0, "Got expected frameId"); + Assert.equal( + actual[0].tabId, + expectedTabContext.tabId, + "Got expected tabId" + ); + Assert.notEqual( + actual[0].contextId, + initialTabContextId, + "Expect contextId to change on navigated tab" + ); + } + ); + + await triggerActionPopup(extension, privateWin, async () => { + info("Wait the extension page to be fully loaded in the action popup"); + await extension.awaitMessage("action-loaded"); + + const expectedPopupContext = getExpectedExtensionContext({ + contextType: "POPUP", + documentUrl: resolveExtPageUrl("page.html?kind=action"), + windowId: privateWinId, + tabId: -1, + incognito: true, + }); + + info("Test getContexts with contextTypes POPUP filter"); + let actual = await getGetContextsResults({ + filter: { + contextTypes: ["POPUP"], + }, + }); + assertGetContextsResult( + actual, + [expectedPopupContext], + "Got the expected results from runtime.getContexts (with contextTypes POPUP filter)" + ); + + info("Test getContexts with incognito true filter"); + actual = await getGetContextsResults({ + filter: { incognito: true }, + sortBy: byContextType, + }); + assertGetContextsResult( + actual.sort(byContextType), + [expectedPopupContext, ...expected.filter(it => it.incognito)].sort( + byContextType + ), + "Got the expected results from runtime.getContexts (with contextTypes incognito true filter)" + ); + }); + + info("Test getContexts with existing background iframes"); + extension.sendMessage( + `background-create-iframe`, + resolveExtPageUrl("page.html?kind=background-subframe") + ); + await extension.awaitMessage(`background-subframe-loaded`); + + actual = await getGetContextsResults({ + filter: { contextTypes: ["BACKGROUND"] }, + }); + + Assert.equal( + actual.length, + 2, + "Expect 2 background extension contexts to be found" + ); + const bgTopFrame = actual.find( + it => it.documentUrl === resolveExtPageUrl("page.html?kind=background") + ); + const bgSubFrame = actual.find( + it => + it.documentUrl === resolveExtPageUrl("page.html?kind=background-subframe") + ); + + assertValidContextId(bgTopFrame.contextId); + assertValidContextId(bgSubFrame.contextId); + Assert.notEqual( + bgTopFrame.contextId, + bgSubFrame.contextId, + "Expect background top and sub frame to have different contextIds" + ); + + Assert.equal( + bgTopFrame.frameId, + 0, + "Expect background top frame to have frameId 0" + ); + ok( + typeof bgSubFrame.frameId === "number" && bgSubFrame.frameId > 0, + "Expect background sub frame to have a non zero frameId" + ); + Assert.equal( + bgSubFrame.windowId, + bgSubFrame.windowId, + "Expect background top frame to have same windowId as the top frame" + ); + Assert.equal( + bgSubFrame.tabId, + bgTopFrame.tabId, + "Expect background top frame to have same tabId as the top frame" + ); + + info("Test getContexts with existing sidebars iframes"); + extension.sendMessage( + `sidebar-create-iframe`, + resolveExtPageUrl("page.html?kind=sidebar-subframe") + ); + // Expect 3 sidebar subframe to be created. + await extension.awaitMessage(`sidebar-subframe-loaded`); + await extension.awaitMessage(`sidebar-subframe-loaded`); + await extension.awaitMessage(`sidebar-subframe-loaded`); + + actual = await getGetContextsResults({ + filter: { contextTypes: ["SIDE_PANEL"], windowIds: [firstWinId] }, + sortBy: byFrameId, + }); + Assert.equal( + actual.length, + 2, + "Expect 2 sidebar extension contexts to be found for the first window" + ); + // NOTE: we have already asserted that the sidebar top level frame has frameId 0. + Assert.greater( + actual.find( + it => + it.documentUrl == resolveExtPageUrl("page.html?kind=sidebar-subframe") + )?.frameId, + 0, + "Expect sidebar subframe to have the expected frameId" + ); + // NOTE: we have already asserted that the top level frame have tabId -1. + Assert.equal( + actual[0].tabId, + actual[1].tabId, + "Expect iframe and top level sidebar frame to have the same tabId" + ); + + actual = await getGetContextsResults({ + filter: { contextTypes: ["SIDE_PANEL"], windowIds: [secondWinId] }, + sortBy: byFrameId, + }); + Assert.equal( + actual.length, + 2, + "Expect 2 sidebar extension contexts to be found for the second window" + ); + + actual = await getGetContextsResults({ + filter: { contextTypes: ["SIDE_PANEL"], incognito: true }, + }); + Assert.equal( + actual.length, + 2, + "Expect 2 sidebar extension contexts to be found for private windows" + ); + + info("Test getContexts after background history push state"); + let pushStateURLPath = "/page.html?kind=background&pushedState=1"; + extension.sendMessage("background-history-push-state", pushStateURLPath); + await extension.awaitMessage("background-history-push-state:done"); + + actual = await getGetContextsResults({ + filter: { contextTypes: ["BACKGROUND"], frameIds: [0] }, + }); + Assert.equal( + actual.length, + 1, + "Expect 1 top level background context to be found" + ); + Assert.equal( + actual[0].contextId, + bgTopFrame.contextId, + "Expect top level background contextId to NOT be changed" + ); + Assert.equal( + actual[0].documentUrl, + resolveExtPageUrl(pushStateURLPath), + "Expect top level background documentUrl to change due to history.pushState" + ); + + await BrowserTestUtils.closeWindow(privateWin); + await BrowserTestUtils.closeWindow(secondWin); + + info( + "Test getContexts after opening an options page embedded in an about:addons tab" + ); + await BrowserTestUtils.withNewTab("about:addons", async () => { + extension.sendMessage("background-open-options-page"); + await extension.awaitMessage("options-loaded"); + const { selectedBrowser } = firstWin.gBrowser; + Assert.equal( + selectedBrowser.currentURI.spec, + "about:addons", + "Expect an about:addons tab to be current active tab" + ); + let optionsTabId = tabTracker.getBrowserData(selectedBrowser).tabId; + + actual = await getGetContextsResults({ + filter: { windowIds: [firstWinId], tabIds: [optionsTabId] }, + }); + assertGetContextsResult( + actual, + [ + getExpectedExtensionContext({ + contextType: "TAB", + documentUrl: resolveExtPageUrl("page.html?kind=options"), + windowId: firstWinId, + tabId: optionsTabId, + }), + ], + "Got the expected results from runtime.getContexts for an options_page" + ); + }); + + info("Test getContexts with an extension devtools page and devtools panel"); + await BrowserTestUtils.withNewTab("https://example.com", async () => { + const tab = gBrowser.selectedTab; + const toolbox = await openToolboxForTab(tab); + + info("Wait for the devtools page to be loaded"); + await extension.awaitMessage("devtools-page-loaded"); + + Assert.equal( + toolbox.getAdditionalTools()?.length, + 1, + "Expecte extension devtools panel to be registered" + ); + let panelId = toolbox.getAdditionalTools()[0].id; + await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + + info("Wait for the devtools panel to be loaded"); + await extension.awaitMessage("devtools-panel-loaded"); + + actual = await getGetContextsResults({ filter: {} }); + // Expect the backgrond page and its subframe to still be returned. + Assert.equal( + actual.filter(ctx => ctx.contextType === "BACKGROUND").length, + 2, + "Expect the existing 2 background context types" + ); + // Expect the side_panel page and its subframe to still be returned. + Assert.equal( + actual.filter(ctx => ctx.contextType === "SIDE_PANEL").length, + 2, + "Expect the existing 2 side_panel context types" + ); + // Expect no other context to be listed in the getContexts results + // (devtools page and panel are currently expected to not be + // part of getContexts results). + Assert.deepEqual( + actual.filter( + ctx => !["BACKGROUND", "SIDE_PANEL"].includes(ctx.contextType) + ), + [], + "DevTools page and panel are not listed in getContexts results" + ); + + await closeToolboxForTab(gBrowser.selectedTab); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js index 7dd41ecfe9..715cf14458 100644 --- a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js @@ -7,7 +7,7 @@ requestLongerTimeout(2); loadTestSubscript("head_sessions.js"); add_task(async function test_sessions_get_recently_closed() { - async function openAndCloseWindow(url = "http://example.com", tabUrls) { + async function openAndCloseWindow(url = "https://example.com", tabUrls) { let win = await BrowserTestUtils.openNewBrowserWindow(); BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); @@ -77,13 +77,13 @@ add_task(async function test_sessions_get_recently_closed() { let tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, - "http://example.com" + "https://example.com" ); BrowserTestUtils.removeTab(tab); tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, - "http://example.com" + "https://example.com" ); BrowserTestUtils.removeTab(tab); @@ -123,7 +123,7 @@ add_task(async function test_sessions_get_recently_closed_navigated() { .then(recentlyClosed => { let tab = recentlyClosed[0].window.tabs[0]; browser.test.assertEq( - "http://example.com/", + "https://example.com/", tab.url, "Tab in closed window has the expected url." ); @@ -144,7 +144,7 @@ add_task(async function test_sessions_get_recently_closed_navigated() { // Test with a window with navigation history. let win = await BrowserTestUtils.openNewBrowserWindow(); - for (let url of ["about:robots", "about:mozilla", "http://example.com/"]) { + for (let url of ["about:robots", "about:mozilla", "https://example.com/"]) { BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); } @@ -178,7 +178,7 @@ add_task( "The second tab with empty.xpi has no url field due to empty history." ); browser.test.assertEq( - "http://example.com/", + "https://example.com/", win.tabs[2].url, "The third tab is example.com." ); @@ -195,7 +195,7 @@ add_task( // Test with a window with empty history. let xpi = - "http://example.com/browser/browser/components/extensions/test/browser/empty.xpi"; + "https://example.com/browser/browser/components/extensions/test/browser/empty.xpi"; let newWin = await BrowserTestUtils.openNewBrowserWindow(); await BrowserTestUtils.openNewForegroundTab({ gBrowser: newWin.gBrowser, @@ -205,7 +205,7 @@ add_task( }); await BrowserTestUtils.openNewForegroundTab({ gBrowser: newWin.gBrowser, - url: "http://example.com/", + url: "https://example.com/", }); await BrowserTestUtils.closeWindow(newWin); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js b/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js index c89e9d39dc..46a925039a 100644 --- a/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js @@ -15,7 +15,7 @@ ChromeUtils.defineESModuleGetters(this, { // Check that we can restore a tab modified by an extension. add_task(async function test_restoringModifiedTab() { function background() { - browser.tabs.create({ url: "http://example.com/" }); + browser.tabs.create({ url: "https://example.com/" }); browser.test.onMessage.addListener(msg => { if (msg == "change-tab") { browser.tabs.executeScript({ code: 'location.href += "?changedTab";' }); @@ -32,14 +32,14 @@ add_task(async function test_restoringModifiedTab() { background, }); - const contentScriptTabURL = "http://example.com/?changedTab"; + const contentScriptTabURL = "https://example.com/?changedTab"; let win = await BrowserTestUtils.openNewBrowserWindow({}); // Open and close a tabs. let tabPromise = BrowserTestUtils.waitForNewTab( win.gBrowser, - "http://example.com/", + "https://example.com/", true ); await extension.startup(); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js index 8498f73071..ac2d19cf23 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js @@ -7,6 +7,7 @@ requestLongerTimeout(2); let extData = { manifest: { sidebar_action: { + default_icon: "default.png", default_panel: "sidebar.html", }, }, @@ -29,6 +30,9 @@ let extData = { browser.test.sendMessage("sidebar"); }; }, + + "default.png": imageBuffer, + "1.png": imageBuffer, }, background: function () { @@ -44,6 +48,8 @@ let extData = { let { arg = {}, result } = data; let isOpen = await browser.sidebarAction.isOpen(arg); browser.test.assertEq(result, isOpen, "expected value from isOpen"); + } else if (msg === "set-icon") { + await browser.sidebarAction.setIcon({ path: data }); } browser.test.sendMessage("done"); }); @@ -148,7 +154,7 @@ add_task(async function sidebar_isOpen() { info("Test extension1's sidebar is opened on install"); await extension1.awaitMessage("sidebar"); await sendMessage(extension1, "isOpen", { result: true }); - let sidebar1ID = SidebarUI.currentID; + let sidebar1ID = SidebarController.currentID; info("Load extension2"); let extension2 = ExtensionTestUtils.loadExtension(getExtData()); @@ -160,7 +166,7 @@ add_task(async function sidebar_isOpen() { await sendMessage(extension2, "isOpen", { result: true }); info("Switch back to extension1's sidebar"); - SidebarUI.show(sidebar1ID); + SidebarController.show(sidebar1ID); await extension1.awaitMessage("sidebar"); await sendMessage(extension1, "isOpen", { result: true }); await sendMessage(extension2, "isOpen", { result: false }); @@ -195,7 +201,7 @@ add_task(async function sidebar_isOpen() { newWin.close(); info("Close the sidebar in the original window"); - SidebarUI.hide(); + SidebarController.hide(); await sendMessage(extension1, "isOpen", { result: false }); await sendMessage(extension2, "isOpen", { result: false }); @@ -223,11 +229,15 @@ add_task(async function testShortcuts() { async function toggleSwitcherPanel(win = window) { // Open and close the switcher panel to trigger shortcut content rendering. - let switcherPanelShown = promisePopupShown(win.SidebarUI._switcherPanel); - win.SidebarUI.showSwitcherPanel(); + let switcherPanelShown = promisePopupShown( + win.SidebarController._switcherPanel + ); + win.SidebarController.showSwitcherPanel(); await switcherPanelShown; - let switcherPanelHidden = promisePopupHidden(win.SidebarUI._switcherPanel); - win.SidebarUI.hideSwitcherPanel(); + let switcherPanelHidden = promisePopupHidden( + win.SidebarController._switcherPanel + ); + win.SidebarController.hideSwitcherPanel(); await switcherPanelHidden; } @@ -301,3 +311,39 @@ add_task(async function testShortcuts() { await BrowserTestUtils.closeWindow(win); }); + +add_task(async function sidebar_switcher_panel_icon_update() { + info("Load extension"); + const extension = ExtensionTestUtils.loadExtension(getExtData()); + await extension.startup(); + + info("Test extension's sidebar is opened on install"); + await extension.awaitMessage("sidebar"); + await sendMessage(extension, "isOpen", { result: true }); + const sidebarID = SidebarController.currentID; + + const item = SidebarController._switcherPanel.querySelector( + ".webextension-menuitem" + ); + let iconUrl = `moz-extension://${extension.uuid}/default.png`; + is( + item.style.getPropertyValue("--webextension-menuitem-image"), + `image-set(url("${iconUrl}"), url("${iconUrl}") 2x)`, + "Extension has the correct icon." + ); + SidebarController.hide(); + await sendMessage(extension, "isOpen", { result: false }); + + await sendMessage(extension, "set-icon", "1.png"); + await SidebarController.show(sidebarID); + await extension.awaitMessage("sidebar"); + await sendMessage(extension, "isOpen", { result: true }); + iconUrl = `moz-extension://${extension.uuid}/1.png`; + is( + item.style.getPropertyValue("--webextension-menuitem-image"), + `image-set(url("${iconUrl}"), url("${iconUrl}") 2x)`, + "Extension has updated icon." + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js index 621d2d1180..a82f6b8186 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js @@ -54,7 +54,7 @@ add_task(async function test_sidebar_click_isAppTab_behavior() { await extension.awaitMessage("sidebar-ready"); // This test fails if docShell.isAppTab has not been set to true. - let content = SidebarUI.browser.contentWindow; + let content = SidebarController.browser.contentWindow; // Wait for the layout to be flushed, otherwise this test may // fail intermittently if synthesizeMouseAtCenter is being called diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js index 7057037a5e..93d34d4487 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js @@ -124,7 +124,7 @@ async function runTests(options) { }); // Wait for initial sidebar load. - SidebarUI.browser.addEventListener( + SidebarController.browser.addEventListener( "load", async () => { // Wait for the background page listeners to be ready and diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js index d50d96b822..f8ab238148 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js @@ -42,7 +42,7 @@ add_task(async function sidebar_httpAuthPrompt() { // Wait for the http auth prompt and close it with accept button. let promptPromise = PromptTestUtils.handleNextPrompt( - SidebarUI.browser.contentWindow, + SidebarController.browser.contentWindow, { modalType: Services.prompt.MODAL_TYPE_WINDOW, promptType: "promptUserAndPass", diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js index 221447cf2e..39269d9d06 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js @@ -124,11 +124,14 @@ add_task(async function test_sidebarAction_not_allowed() { await extension.startup(); let sidebarID = `${makeWidgetId(extension.id)}-sidebar-action`; - ok(SidebarUI.sidebars.has(sidebarID), "sidebar exists in non-private window"); + ok( + SidebarController.sidebars.has(sidebarID), + "sidebar exists in non-private window" + ); let winData = await getIncognitoWindow(); - let hasSidebar = winData.win.SidebarUI.sidebars.has(sidebarID); + let hasSidebar = winData.win.SidebarController.sidebars.has(sidebarID); ok(!hasSidebar, "sidebar does not exist in private window"); // Test API access to private window data. extension.sendMessage(winData.details); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js index 55c83ee0b1..8ac1dd76a1 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js @@ -52,9 +52,10 @@ add_task(async function test_sidebar_disconnect() { await connected; // Bug 1445080 fixes currentURI, test to avoid future breakage. - let currentURI = window.SidebarUI.browser.contentDocument.getElementById( - "webext-panels-browser" - ).currentURI; + let currentURI = + window.SidebarController.browser.contentDocument.getElementById( + "webext-panels-browser" + ).currentURI; is(currentURI.scheme, "moz-extension", "currentURI is set correctly"); // switching sidebar to another extension @@ -68,7 +69,7 @@ add_task(async function test_sidebar_disconnect() { // switching sidebar to built-in sidebar let disconnected = extension2.awaitMessage("disconnected"); - window.SidebarUI.show("viewBookmarksSidebar"); + window.SidebarController.show("viewBookmarksSidebar"); await disconnected; await extension.unload(); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js index 58f2b07797..c8f3f043ec 100644 --- a/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js @@ -49,7 +49,7 @@ add_task(async function sidebar_windows() { let secondSidebar = extension.awaitMessage("sidebar"); - // SidebarUI relies on window.opener being set, which is normal behavior when + // SidebarController relies on window.opener being set, which is normal behavior when // using menu or key commands to open a new browser window. let win = await BrowserTestUtils.openNewBrowserWindow(); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions.js b/browser/components/extensions/test/browser/browser_unified_extensions.js index 2c65f47c4e..b83d4c2e23 100644 --- a/browser/components/extensions/test/browser/browser_unified_extensions.js +++ b/browser/components/extensions/test/browser/browser_unified_extensions.js @@ -44,6 +44,9 @@ add_setup(async function () { // panel, which could happen when a previous test file resizes the current // window. await ensureMaximizedWindow(window); + await SpecialPowers.pushPrefEnv({ + set: [["extensions.originControls.grantByDefault", false]], + }); }); add_task(async function test_button_enabled_by_pref() { diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js b/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js index 44d861d97c..8439cf30dd 100644 --- a/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js +++ b/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js @@ -5,6 +5,12 @@ loadTestSubscript("head_unified_extensions.js"); +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.originControls.grantByDefault", false]], + }); +}); + add_task(async function test_keyboard_navigation_activeScript() { const extension1 = ExtensionTestUtils.loadExtension({ manifest: { diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js b/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js index d04d85e535..a7bdc8273c 100644 --- a/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js +++ b/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js @@ -5,10 +5,6 @@ requestLongerTimeout(2); -ChromeUtils.defineESModuleGetters(this, { - AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", -}); - const { EnterprisePolicyTesting } = ChromeUtils.importESModule( "resource://testing-common/EnterprisePolicyTesting.sys.mjs" ); @@ -31,21 +27,6 @@ const promiseExtensionUninstalled = extensionId => { }); }; -function waitClosedWindow(win) { - return new Promise(resolve => { - function onWindowClosed() { - if (win && !win.closed) { - // If a specific window reference has been passed, then check - // that the window is closed before resolving the promise. - return; - } - Services.obs.removeObserver(onWindowClosed, "xul-window-destroyed"); - resolve(); - } - Services.obs.addObserver(onWindowClosed, "xul-window-destroyed"); - }); -} - function assertVisibleContextMenuItems(contextMenu, expected) { let visibleItems = contextMenu.querySelectorAll( ":is(menuitem, menuseparator):not([hidden])" @@ -366,90 +347,33 @@ add_task(async function test_report_extension() { // closed and about:addons is open with the "abuse report dialog". const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); - if (AbuseReporter.amoFormEnabled) { - const reportURL = Services.urlFormatter - .formatURLPref("extensions.abuseReport.amoFormURL") - .replace("%addonID%", extension.id); - - const promiseReportTab = BrowserTestUtils.waitForNewTab( - gBrowser, - reportURL, - /* waitForLoad */ false, - // Do not expect it to be the next tab opened - /* waitForAnyTab */ true - ); - contextMenu.activateItem(reportButton); - const [reportTab] = await Promise.all([promiseReportTab, hidden]); - // Remove the report tab and expect the selected tab - // to become the about:addons tab. - BrowserTestUtils.removeTab(reportTab); - if (AbuseReporter.amoFormEnabled) { - is( - gBrowser.selectedBrowser.currentURI.spec, - "about:blank", - "Expect about:addons tab to have not been opened (amoFormEnabled=true)" - ); - } else { - is( - gBrowser.selectedBrowser.currentURI.spec, - "about:addons", - "Got about:addons tab selected (amoFormEnabled=false)" - ); - } - return; - } + const reportURL = Services.urlFormatter + .formatURLPref("extensions.abuseReport.amoFormURL") + .replace("%addonID%", extension.id); - const abuseReportOpen = BrowserTestUtils.waitForCondition( - () => AbuseReporter.getOpenDialog(), - "wait for the abuse report dialog to have been opened" + const promiseReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + reportURL, + /* waitForLoad */ false, + // Do not expect it to be the next tab opened + /* waitForAnyTab */ true ); contextMenu.activateItem(reportButton); - const [reportDialogWindow] = await Promise.all([abuseReportOpen, hidden]); - - const reportDialogParams = - reportDialogWindow.arguments[0].wrappedJSObject; - is( - reportDialogParams.report.addon.id, - extension.id, - "abuse report dialog has the expected addon id" - ); + const [reportTab] = await Promise.all([promiseReportTab, hidden]); + // Remove the report tab and expect the selected tab + // to become the about:addons tab. + BrowserTestUtils.removeTab(reportTab); is( - reportDialogParams.report.reportEntryPoint, - "unified_context_menu", - "abuse report dialog has the expected reportEntryPoint" + gBrowser.selectedBrowser.currentURI.spec, + "about:blank", + "Expect about:addons tab to have not been opened" ); - - let promiseClosedWindow = waitClosedWindow(); - reportDialogWindow.close(); - // Wait for the report dialog window to be completely closed - // (to prevent an intermittent failure due to a race between - // the dialog window being closed and the test tasks that follows - // opening the unified extensions button panel to not lose the - // focus and be suddently closed before the task has done with - // its assertions, see Bug 1782304). - await promiseClosedWindow; }); } const [ext] = createExtensions([{ name: "an extension" }]); await ext.startup(); - - info("Test report with amoFormEnabled=true"); - - await SpecialPowers.pushPrefEnv({ - set: [["extensions.abuseReport.amoFormEnabled", true]], - }); await runReportTest(ext); - await SpecialPowers.popPrefEnv(); - - info("Test report with amoFormEnabled=false"); - - await SpecialPowers.pushPrefEnv({ - set: [["extensions.abuseReport.amoFormEnabled", false]], - }); - await runReportTest(ext); - await SpecialPowers.popPrefEnv(); - await ext.unload(); }); diff --git a/browser/components/extensions/test/browser/head.js b/browser/components/extensions/test/browser/head.js index 344eb3cca7..2d6835a034 100644 --- a/browser/components/extensions/test/browser/head.js +++ b/browser/components/extensions/test/browser/head.js @@ -31,6 +31,7 @@ * loadTestSubscript awaitBrowserLoaded * getScreenAt roundCssPixcel getCssAvailRect isRectContained * getToolboxBackgroundColor + * promiseBrowserContentUnloaded */ // There are shutdown issues for which multiple rejections are left uncaught. @@ -167,6 +168,44 @@ function promiseAnimationFrame(win = window) { return AppUiTestInternals.promiseAnimationFrame(win); } +async function promiseBrowserContentUnloaded(browser) { + // Wait until the content has unloaded before resuming the test, to avoid + // calling extension.getViews too early (and having intermittent failures). + const MSG_WINDOW_DESTROYED = "Test:BrowserContentDestroyed"; + let unloadPromise = new Promise(resolve => { + Services.ppmm.addMessageListener(MSG_WINDOW_DESTROYED, function listener() { + Services.ppmm.removeMessageListener(MSG_WINDOW_DESTROYED, listener); + resolve(); + }); + }); + + await ContentTask.spawn( + browser, + MSG_WINDOW_DESTROYED, + MSG_WINDOW_DESTROYED => { + let innerWindowId = this.content.windowGlobalChild.innerWindowId; + let observer = subject => { + if ( + innerWindowId === subject.QueryInterface(Ci.nsISupportsPRUint64).data + ) { + Services.obs.removeObserver(observer, "inner-window-destroyed"); + + // Use process message manager to ensure that the message is delivered + // even after the <browser>'s message manager is disconnected. + Services.cpmm.sendAsyncMessage(MSG_WINDOW_DESTROYED); + } + }; + // Observe inner-window-destroyed, like ExtensionPageChild, to ensure that + // the ExtensionPageContextChild instance has been unloaded when we resolve + // the unloadPromise. + Services.obs.addObserver(observer, "inner-window-destroyed"); + } + ); + + // Return an object so that callers can use "await". + return { unloadPromise }; +} + function promisePopupHidden(popup) { return new Promise(resolve => { let onPopupHidden = () => { @@ -437,10 +476,11 @@ async function openContextMenuInPopup( } async function openContextMenuInSidebar(selector = "body") { - let contentAreaContextMenu = SidebarUI.browser.contentDocument.getElementById( - "contentAreaContextMenu" - ); - let browser = SidebarUI.browser.contentDocument.getElementById( + let contentAreaContextMenu = + SidebarController.browser.contentDocument.getElementById( + "contentAreaContextMenu" + ); + let browser = SidebarController.browser.contentDocument.getElementById( "webext-panels-browser" ); let popupShownPromise = BrowserTestUtils.waitForEvent( @@ -452,7 +492,9 @@ async function openContextMenuInSidebar(selector = "body") { // fail intermittently if synthesizeMouseAtCenter is being called // while the sidebar is still opening and the browser window layout // being recomputed. - await SidebarUI.browser.contentWindow.promiseDocumentFlushed(() => {}); + await SidebarController.browser.contentWindow.promiseDocumentFlushed( + () => {} + ); info("Opening context menu in sidebarAction panel"); await BrowserTestUtils.synthesizeMouseAtCenter( diff --git a/browser/components/firefoxview/HistoryController.mjs b/browser/components/firefoxview/HistoryController.mjs deleted file mode 100644 index d2bda5cec2..0000000000 --- a/browser/components/firefoxview/HistoryController.mjs +++ /dev/null @@ -1,188 +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, { - 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/HistoryController.sys.mjs b/browser/components/firefoxview/HistoryController.sys.mjs new file mode 100644 index 0000000000..b6f316e8e7 --- /dev/null +++ b/browser/components/firefoxview/HistoryController.sys.mjs @@ -0,0 +1,383 @@ +/* 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 = {}; + +import { getLogger } from "chrome://browser/content/firefoxview/helpers.mjs"; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesQuery: "resource://gre/modules/PlacesQuery.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", + }, +}; + +/** + * A list of visits displayed on a card. + * + * @typedef {object} CardEntry + * + * @property {string} domain + * @property {HistoryVisit[]} items + * @property {string} l10nId + */ + +export class HistoryController { + /** + * @type {{ entries: CardEntry[]; searchQuery: string; sortOption: string; }} + */ + historyCache; + host; + searchQuery; + sortOption; + #todaysDate; + #yesterdaysDate; + + constructor(host, options) { + this.placesQuery = new lazy.PlacesQuery(); + this.searchQuery = ""; + this.sortOption = "date"; + this.searchResultsLimit = options?.searchResultsLimit || 300; + this.component = HISTORY_MAP_L10N_IDS?.[options?.component] + ? options?.component + : "firefoxview"; + this.historyCache = { + entries: [], + searchQuery: null, + sortOption: null, + }; + this.host = host; + + host.addController(this); + } + + hostConnected() { + this.placesQuery.observeHistory(historyMap => this.updateCache(historyMap)); + } + + hostDisconnected() { + ChromeUtils.idleDispatch(() => this.placesQuery.close()); + } + + deleteFromHistory() { + lazy.PlacesUtils.history.remove(this.host.triggerNode.url); + } + + onSearchQuery(e) { + this.searchQuery = e.detail.query; + this.updateCache(); + } + + onChangeSortOption(e) { + this.sortOption = e.target.value; + this.updateCache(); + } + + get historyVisits() { + return this.historyCache.entries; + } + + get searchResults() { + return this.historyCache.searchQuery + ? this.historyCache.entries[0].items + : null; + } + + get totalVisitsCount() { + return this.historyVisits.reduce( + (count, entry) => count + entry.items.length, + 0 + ); + } + + get isHistoryEmpty() { + return !this.historyVisits.length; + } + + /** + * Update cached history. + * + * @param {Map<CacheKey, HistoryVisit[]>} [historyMap] + * If provided, performs an update using the given data (instead of fetching + * it from the db). + */ + async updateCache(historyMap) { + const { searchQuery, sortOption } = this; + const entries = searchQuery + ? await this.#getVisitsForSearchQuery(searchQuery) + : await this.#getVisitsForSortOption(sortOption, historyMap); + if (this.searchQuery !== searchQuery || this.sortOption !== sortOption) { + // This query is stale, discard results and do not update the cache / UI. + return; + } + for (const { items } of entries) { + for (const item of items) { + this.#normalizeVisit(item); + } + } + this.historyCache = { entries, searchQuery, sortOption }; + this.host.requestUpdate(); + } + + /** + * Normalize data for fxview-tabs-list. + * + * @param {HistoryVisit} visit + * The visit to format. + */ + #normalizeVisit(visit) { + visit.time = visit.date.getTime(); + visit.title = visit.title || visit.url; + visit.icon = `page-icon:${visit.url}`; + visit.primaryL10nId = "fxviewtabrow-tabs-list-tab"; + visit.primaryL10nArgs = JSON.stringify({ + targetURI: visit.url, + }); + visit.secondaryL10nId = "fxviewtabrow-options-menu-button"; + visit.secondaryL10nArgs = JSON.stringify({ + tabTitle: visit.title || visit.url, + }); + } + + async #getVisitsForSearchQuery(searchQuery) { + let items = []; + try { + items = await this.placesQuery.searchHistory( + searchQuery, + this.searchResultsLimit + ); + } catch (e) { + getLogger("HistoryController").warn( + "There is a new search query in progress, so cancelling this one.", + e + ); + } + return [{ items }]; + } + + async #getVisitsForSortOption(sortOption, historyMap) { + if (!historyMap) { + historyMap = await this.#fetchHistory(); + } + switch (sortOption) { + case "date": + this.#setTodaysDate(); + return this.#getVisitsForDate(historyMap); + case "site": + return this.#getVisitsForSite(historyMap); + default: + return []; + } + } + + #setTodaysDate() { + const now = new Date(); + this.#todaysDate = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ); + this.#yesterdaysDate = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() - 1 + ); + } + + /** + * Get a list of visits, sorted by date, in reverse chronological order. + * + * @param {Map<number, HistoryVisit[]>} historyMap + * @returns {CardEntry[]} + */ + #getVisitsForDate(historyMap) { + const entries = []; + const visitsFromToday = this.#getVisitsFromToday(historyMap); + const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap); + const visitsByDay = this.#getVisitsByDay(historyMap); + const visitsByMonth = this.#getVisitsByMonth(historyMap); + + // Add visits from today and yesterday. + if (visitsFromToday.length) { + entries.push({ + l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"], + items: visitsFromToday, + }); + } + if (visitsFromYesterday.length) { + entries.push({ + l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"], + items: visitsFromYesterday, + }); + } + + // Add visits from this month, grouped by day. + visitsByDay.forEach(visits => { + entries.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 => { + entries.push({ + l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"], + items: visits, + }); + }); + return entries; + } + + #getVisitsFromToday(cachedHistory) { + const mapKey = this.placesQuery.getStartOfDayTimestamp(this.#todaysDate); + const visits = cachedHistory.get(mapKey) ?? []; + return [...visits]; + } + + #getVisitsFromYesterday(cachedHistory) { + const mapKey = this.placesQuery.getStartOfDayTimestamp( + this.#yesterdaysDate + ); + const visits = cachedHistory.get(mapKey) ?? []; + return [...visits]; + } + + /** + * Get a list of visits per day for each day on this month, excluding today + * and yesterday. + * + * @param {Map<number, HistoryVisit[]>} cachedHistory + * The history cache to process. + * @returns {HistoryVisit[][]} + * A list of visits for each day. + */ + #getVisitsByDay(cachedHistory) { + const visitsPerDay = []; + for (const [time, visits] of cachedHistory.entries()) { + const date = new Date(time); + if ( + this.#isSameDate(date, this.#todaysDate) || + this.#isSameDate(date, this.#yesterdaysDate) + ) { + continue; + } else if (!this.#isSameMonth(date, this.#todaysDate)) { + break; + } else { + visitsPerDay.push(visits); + } + } + return visitsPerDay; + } + + /** + * Get a list of visits per month for each month, excluding this one, and + * excluding yesterday's visits if yesterday happens to fall on the previous + * month. + * + * @param {Map<number, HistoryVisit[]>} cachedHistory + * The history cache to process. + * @returns {HistoryVisit[][]} + * A list of visits for each month. + */ + #getVisitsByMonth(cachedHistory) { + const visitsPerMonth = []; + let previousMonth = null; + for (const [time, visits] of cachedHistory.entries()) { + const date = new Date(time); + if ( + this.#isSameMonth(date, this.#todaysDate) || + this.#isSameDate(date, this.#yesterdaysDate) + ) { + continue; + } + const month = this.placesQuery.getStartOfMonthTimestamp(date); + if (month !== previousMonth) { + visitsPerMonth.push(visits); + } else { + visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth + .at(-1) + .concat(visits); + } + previousMonth = month; + } + return visitsPerMonth; + } + + /** + * Given two date instances, check if their dates are equivalent. + * + * @param {Date} dateToCheck + * @param {Date} date + * @returns {boolean} + * Whether both date instances have equivalent dates. + */ + #isSameDate(dateToCheck, date) { + return ( + dateToCheck.getDate() === date.getDate() && + this.#isSameMonth(dateToCheck, date) + ); + } + + /** + * Given two date instances, check if their months are equivalent. + * + * @param {Date} dateToCheck + * @param {Date} month + * @returns {boolean} + * Whether both date instances have equivalent months. + */ + #isSameMonth(dateToCheck, month) { + return ( + dateToCheck.getMonth() === month.getMonth() && + dateToCheck.getFullYear() === month.getFullYear() + ); + } + + /** + * Get a list of visits, sorted by site, in alphabetical order. + * + * @param {Map<string, HistoryVisit[]>} historyMap + * @returns {CardEntry[]} + */ + #getVisitsForSite(historyMap) { + return Array.from(historyMap.entries(), ([domain, items]) => ({ + domain, + items, + l10nId: domain ? null : "firefoxview-history-site-localhost", + })).sort((a, b) => a.domain.localeCompare(b.domain)); + } + + async #fetchHistory() { + return this.placesQuery.getHistory({ + daysOld: 60, + limit: lazy.maxRowsPref, + sortBy: this.sortOption, + }); + } +} diff --git a/browser/components/firefoxview/SyncedTabsController.sys.mjs b/browser/components/firefoxview/SyncedTabsController.sys.mjs index 6ab8249bfe..9462766545 100644 --- a/browser/components/firefoxview/SyncedTabsController.sys.mjs +++ b/browser/components/firefoxview/SyncedTabsController.sys.mjs @@ -10,7 +10,7 @@ ChromeUtils.defineESModuleGetters(lazy, { 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"; +import { searchTabList } from "chrome://browser/content/firefoxview/search-helpers.mjs"; const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; diff --git a/browser/components/firefoxview/card-container.mjs b/browser/components/firefoxview/card-container.mjs index 1755d97555..84c6acc5c4 100644 --- a/browser/components/firefoxview/card-container.mjs +++ b/browser/components/firefoxview/card-container.mjs @@ -132,76 +132,71 @@ class CardContainer extends MozLitElement { rel="stylesheet" href="chrome://browser/content/firefoxview/card-container.css" /> - <section - aria-labelledby="header" - aria-label=${ifDefined(this.sectionLabel)} - > - ${when( - this.toggleDisabled, - () => html`<div - class=${classMap({ - "card-container": true, - inner: this.isInnerCard, - "empty-state": this.isEmptyState && !this.isInnerCard, - })} + ${when( + this.toggleDisabled, + () => html`<div + class=${classMap({ + "card-container": true, + inner: this.isInnerCard, + "empty-state": this.isEmptyState && !this.isInnerCard, + })} + > + <span + id="header" + class="card-container-header" + ?hidden=${ifDefined(this.hideHeader)} + toggleDisabled + ?withViewAll=${this.showViewAll} > - <span - id="header" - class="card-container-header" - ?hidden=${ifDefined(this.hideHeader)} - toggleDisabled - ?withViewAll=${this.showViewAll} - > - <slot name="header"></slot> - <slot name="secondary-header"></slot> - </span> - <a - href="about:firefoxview#${this.shortPageName}" - @click=${this.viewAllClicked} - class="view-all-link" - data-l10n-id="firefoxview-view-all-link" - ?hidden=${!this.showViewAll} - ></a> - <slot name="main"></slot> - <slot name="footer" class="card-container-footer"></slot> - </div>`, - () => html`<details - class=${classMap({ - "card-container": true, - inner: this.isInnerCard, - "empty-state": this.isEmptyState && !this.isInnerCard, - })} - ?open=${this.isExpanded} - ?isOpenTabsView=${this.removeBlockEndMargin} - @toggle=${this.onToggleContainer} + <slot name="header"></slot> + <slot name="secondary-header"></slot> + </span> + <a + href="about:firefoxview#${this.shortPageName}" + @click=${this.viewAllClicked} + class="view-all-link" + data-l10n-id="firefoxview-view-all-link" + ?hidden=${!this.showViewAll} + ></a> + <slot name="main"></slot> + <slot name="footer" class="card-container-footer"></slot> + </div>`, + () => html`<details + class=${classMap({ + "card-container": true, + inner: this.isInnerCard, + "empty-state": this.isEmptyState && !this.isInnerCard, + })} + ?open=${this.isExpanded} + ?isOpenTabsView=${this.removeBlockEndMargin} + @toggle=${this.onToggleContainer} + role=${this.isInnerCard ? "presentation" : "group"} + > + <summary + class="card-container-header" + ?hidden=${ifDefined(this.hideHeader)} + ?withViewAll=${this.showViewAll} > - <summary - id="header" - class="card-container-header" - ?hidden=${ifDefined(this.hideHeader)} - ?withViewAll=${this.showViewAll} - > - <span - class="icon chevron-icon" - aria-role="presentation" - data-l10n-id="firefoxview-collapse-button-${this.isExpanded - ? "hide" - : "show"}" - ></span> - <slot name="header"></slot> - </summary> - <a - href="about:firefoxview#${this.shortPageName}" - @click=${this.viewAllClicked} - class="view-all-link" - data-l10n-id="firefoxview-view-all-link" - ?hidden=${!this.showViewAll} - ></a> - <slot name="main"></slot> - <slot name="footer" class="card-container-footer"></slot> - </details>` - )} - </section> + <span + class="icon chevron-icon" + role="presentation" + data-l10n-id="firefoxview-collapse-button-${this.isExpanded + ? "hide" + : "show"}" + ></span> + <slot name="header"></slot> + </summary> + <a + href="about:firefoxview#${this.shortPageName}" + @click=${this.viewAllClicked} + class="view-all-link" + data-l10n-id="firefoxview-view-all-link" + ?hidden=${!this.showViewAll} + ></a> + <slot name="main"></slot> + <slot name="footer" class="card-container-footer"></slot> + </details>` + )} `; } } diff --git a/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs deleted file mode 100644 index 3f9056a7cd..0000000000 --- a/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs +++ /dev/null @@ -1,112 +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/. */ - -/** - * This module exports the FirefoxViewNotificationManager singleton, which manages the notification state - * for the Firefox View button - */ - -const RECENT_TABS_SYNC = "services.sync.lastTabFetch"; -const SHOULD_NOTIFY_FOR_TABS = "browser.tabs.firefox-view.notify-for-tabs"; -const lazy = {}; - -import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; - -ChromeUtils.defineESModuleGetters(lazy, { - BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", - SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", -}); - -export const FirefoxViewNotificationManager = new (class { - #currentlyShowing; - constructor() { - XPCOMUtils.defineLazyPreferenceGetter( - this, - "lastTabFetch", - RECENT_TABS_SYNC, - 0, - () => { - this.handleTabSync(); - } - ); - XPCOMUtils.defineLazyPreferenceGetter( - this, - "shouldNotifyForTabs", - SHOULD_NOTIFY_FOR_TABS, - false - ); - // Need to access the pref variable for the observer to start observing - // See the defineLazyPreferenceGetter function header - this.lastTabFetch; - - Services.obs.addObserver(this, "firefoxview-notification-dot-update"); - - this.#currentlyShowing = false; - } - - async handleTabSync() { - if (!this.shouldNotifyForTabs) { - return; - } - let newSyncedTabs = await lazy.SyncedTabs.getRecentTabs(3); - this.#currentlyShowing = this.tabsListChanged(newSyncedTabs); - this.showNotificationDot(); - this.syncedTabs = newSyncedTabs; - } - - showNotificationDot() { - if (this.#currentlyShowing) { - Services.obs.notifyObservers( - null, - "firefoxview-notification-dot-update", - "true" - ); - } - } - - observe(sub, topic, data) { - if (topic === "firefoxview-notification-dot-update" && data === "false") { - this.#currentlyShowing = false; - } - } - - tabsListChanged(newTabs) { - // The first time the tabs list is changed this.tabs is undefined because we haven't synced yet. - // We don't want to show the badge here because it's not an actual change, - // we are just syncing for the first time. - if (!this.syncedTabs) { - return false; - } - - // We loop through all windows to see if any window has currentURI "about:firefoxview" and - // the window is visible because we don't want to show the notification badge in that case - for (let window of lazy.BrowserWindowTracker.orderedWindows) { - // if the url is "about:firefoxview" and the window visible we don't want to show the notification badge - if ( - window.FirefoxViewHandler.tab?.selected && - !window.isFullyOccluded && - window.windowState !== window.STATE_MINIMIZED - ) { - return false; - } - } - - if (newTabs.length > this.syncedTabs.length) { - return true; - } - for (let i = 0; i < newTabs.length; i++) { - let newTab = newTabs[i]; - let oldTab = this.syncedTabs[i]; - - if (newTab?.url !== oldTab?.url) { - return true; - } - } - return false; - } - - shouldNotificationDotBeShowing() { - return this.#currentlyShowing; - } -})(); diff --git a/browser/components/firefoxview/firefox-view-places-query.sys.mjs b/browser/components/firefoxview/firefox-view-places-query.sys.mjs deleted file mode 100644 index 8923905769..0000000000 --- a/browser/components/firefoxview/firefox-view-places-query.sys.mjs +++ /dev/null @@ -1,187 +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 { PlacesQuery } from "resource://gre/modules/PlacesQuery.sys.mjs"; - -/** - * Extension of PlacesQuery which provides additional caches for Firefox View. - */ -export class FirefoxViewPlacesQuery extends PlacesQuery { - /** @type {Date} */ - #todaysDate = null; - /** @type {Date} */ - #yesterdaysDate = null; - - get visitsFromToday() { - if (this.cachedHistory == null || this.#todaysDate == null) { - return []; - } - const mapKey = this.getStartOfDayTimestamp(this.#todaysDate); - return this.cachedHistory.get(mapKey) ?? []; - } - - get visitsFromYesterday() { - if (this.cachedHistory == null || this.#yesterdaysDate == null) { - return []; - } - const mapKey = this.getStartOfDayTimestamp(this.#yesterdaysDate); - return this.cachedHistory.get(mapKey) ?? []; - } - - /** - * Get a list of visits per day for each day on this month, excluding today - * and yesterday. - * - * @returns {HistoryVisit[][]} - * A list of visits for each day. - */ - get visitsByDay() { - const visitsPerDay = []; - for (const [time, visits] of this.cachedHistory.entries()) { - const date = new Date(time); - if ( - this.#isSameDate(date, this.#todaysDate) || - this.#isSameDate(date, this.#yesterdaysDate) - ) { - continue; - } else if (!this.#isSameMonth(date, this.#todaysDate)) { - break; - } else { - visitsPerDay.push(visits); - } - } - return visitsPerDay; - } - - /** - * Get a list of visits per month for each month, excluding this one, and - * excluding yesterday's visits if yesterday happens to fall on the previous - * month. - * - * @returns {HistoryVisit[][]} - * A list of visits for each month. - */ - get visitsByMonth() { - const visitsPerMonth = []; - let previousMonth = null; - for (const [time, visits] of this.cachedHistory.entries()) { - const date = new Date(time); - if ( - this.#isSameMonth(date, this.#todaysDate) || - this.#isSameDate(date, this.#yesterdaysDate) - ) { - continue; - } - const month = this.getStartOfMonthTimestamp(date); - if (month !== previousMonth) { - visitsPerMonth.push(visits); - } else { - visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth - .at(-1) - .concat(visits); - } - previousMonth = month; - } - return visitsPerMonth; - } - - formatRowAsVisit(row) { - const visit = super.formatRowAsVisit(row); - this.#normalizeVisit(visit); - return visit; - } - - formatEventAsVisit(event) { - const visit = super.formatEventAsVisit(event); - this.#normalizeVisit(visit); - return visit; - } - - /** - * Normalize data for fxview-tabs-list. - * - * @param {HistoryVisit} visit - * The visit to format. - */ - #normalizeVisit(visit) { - visit.time = visit.date.getTime(); - visit.title = visit.title || visit.url; - visit.icon = `page-icon:${visit.url}`; - visit.primaryL10nId = "fxviewtabrow-tabs-list-tab"; - visit.primaryL10nArgs = JSON.stringify({ - targetURI: visit.url, - }); - visit.secondaryL10nId = "fxviewtabrow-options-menu-button"; - visit.secondaryL10nArgs = JSON.stringify({ - tabTitle: visit.title || visit.url, - }); - } - - async fetchHistory() { - await super.fetchHistory(); - if (this.cachedHistoryOptions.sortBy === "date") { - this.#setTodaysDate(); - } - } - - handlePageVisited(event) { - const visit = super.handlePageVisited(event); - if (!visit) { - return; - } - if ( - this.cachedHistoryOptions.sortBy === "date" && - (this.#todaysDate == null || - (visit.date.getTime() > this.#todaysDate.getTime() && - !this.#isSameDate(visit.date, this.#todaysDate))) - ) { - // If today's date has passed (or is null), it should be updated now. - this.#setTodaysDate(); - } - } - - #setTodaysDate() { - const now = new Date(); - this.#todaysDate = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate() - ); - this.#yesterdaysDate = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate() - 1 - ); - } - - /** - * Given two date instances, check if their dates are equivalent. - * - * @param {Date} dateToCheck - * @param {Date} date - * @returns {boolean} - * Whether both date instances have equivalent dates. - */ - #isSameDate(dateToCheck, date) { - return ( - dateToCheck.getDate() === date.getDate() && - this.#isSameMonth(dateToCheck, date) - ); - } - - /** - * Given two date instances, check if their months are equivalent. - * - * @param {Date} dateToCheck - * @param {Date} month - * @returns {boolean} - * Whether both date instances have equivalent months. - */ - #isSameMonth(dateToCheck, month) { - return ( - dateToCheck.getMonth() === month.getMonth() && - dateToCheck.getFullYear() === month.getFullYear() - ); - } -} diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css index a91c90c39e..0789c887bf 100644 --- a/browser/components/firefoxview/firefoxview.css +++ b/browser/components/firefoxview/firefoxview.css @@ -31,17 +31,6 @@ --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) { diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html index 5bffb5a1d8..bdaa41bd7c 100644 --- a/browser/components/firefoxview/firefoxview.html +++ b/browser/components/firefoxview/firefoxview.html @@ -13,7 +13,6 @@ <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="toolkit/branding/brandings.ftl" /> <link rel="localization" href="browser/migrationWizard.ftl" /> diff --git a/browser/components/firefoxview/fxview-empty-state.mjs b/browser/components/firefoxview/fxview-empty-state.mjs index 9e6bc488fa..3e94767043 100644 --- a/browser/components/firefoxview/fxview-empty-state.mjs +++ b/browser/components/firefoxview/fxview-empty-state.mjs @@ -53,7 +53,6 @@ class FxviewEmptyState extends MozLitElement { return html``; } return html` <a - aria-details="card-container" data-l10n-name=${descriptionLink.name} href=${descriptionLink.url} target=${descriptionLink?.sameTarget ? "_self" : "_blank"} @@ -68,7 +67,7 @@ class FxviewEmptyState extends MozLitElement { /> <card-container hideHeader="true" exportparts="image" ?isInnerCard="${ this.isInnerCard - }" id="card-container" isEmptyState="true"> + }" id="card-container" isEmptyState="true" role="group" aria-labelledby="header" aria-describedby="description"> <div slot="main" class=${classMap({ selectedTab: this.isSelectedTab, imageHidden: !this.mainImageUrl, @@ -98,19 +97,21 @@ class FxviewEmptyState extends MozLitElement { data-l10n-args="${JSON.stringify(this.headerArgs)}"> </span> </h2> - ${repeat( - this.descriptionLabels, - descLabel => descLabel, - (descLabel, index) => html`<p - class=${classMap({ - description: true, - secondary: index !== 0, - })} - data-l10n-id="${descLabel}" - > - ${this.linkTemplate(this.descriptionLink)} - </p>` - )} + <span id="description"> + ${repeat( + this.descriptionLabels, + descLabel => descLabel, + (descLabel, index) => html`<p + class=${classMap({ + description: true, + secondary: index !== 0, + })} + data-l10n-id="${descLabel}" + > + ${this.linkTemplate(this.descriptionLink)} + </p>` + )} + </span> <slot name="primary-action"></slot> </div> </div> diff --git a/browser/components/firefoxview/fxview-search-textbox.mjs b/browser/components/firefoxview/fxview-search-textbox.mjs index 1332f5f3f6..107aa8f7a4 100644 --- a/browser/components/firefoxview/fxview-search-textbox.mjs +++ b/browser/components/firefoxview/fxview-search-textbox.mjs @@ -20,7 +20,7 @@ const SEARCH_DEBOUNCE_TIMEOUT_MS = 1000; * * There is no actual searching done here. That needs to be implemented by the * `fxview-search-textbox-query` event handler. `searchTabList()` from - * `helpers.mjs` can be used as a starting point. + * `search-helpers.mjs` can be used as a starting point. * * @property {string} placeholder * The placeholder text for the search box. diff --git a/browser/components/firefoxview/fxview-tab-list.mjs b/browser/components/firefoxview/fxview-tab-list.mjs index 57181e3bea..63be9379db 100644 --- a/browser/components/firefoxview/fxview-tab-list.mjs +++ b/browser/components/firefoxview/fxview-tab-list.mjs @@ -11,7 +11,7 @@ import { when, } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; -import { escapeRegExp } from "./helpers.mjs"; +import { escapeRegExp } from "./search-helpers.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://global/content/elements/moz-button.mjs"; @@ -49,7 +49,6 @@ if (!window.IS_STORYBOOK) { * @property {number} maxTabsLength - The max number of tabs for the list * @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 */ @@ -65,7 +64,6 @@ export class FxviewTabListBase extends MozLitElement { this.maxTabsLength = 25; this.tabItems = []; this.compactRows = false; - this.searchInProgress = false; this.updatesPaused = true; this.#register(); } @@ -80,12 +78,12 @@ export class FxviewTabListBase extends MozLitElement { tabItems: { type: Array }, updatesPaused: { type: Boolean }, searchQuery: { type: String }, - searchInProgress: { type: Boolean }, secondaryActionClass: { type: String }, tertiaryActionClass: { type: String }, }; static queries = { + emptyState: "fxview-empty-state", rowEls: { all: "fxview-tab-row", }, @@ -308,11 +306,7 @@ export class FxviewTabListBase extends MozLitElement { } render() { - if ( - this.searchQuery && - this.tabItems.length === 0 && - !this.searchInProgress - ) { + if (this.searchQuery && !this.tabItems.length) { return this.emptySearchResultsTemplate(); } return html` diff --git a/browser/components/firefoxview/helpers.mjs b/browser/components/firefoxview/helpers.mjs index b206deef18..fb41fac0e1 100644 --- a/browser/components/firefoxview/helpers.mjs +++ b/browser/components/firefoxview/helpers.mjs @@ -126,27 +126,6 @@ export function isSearchEnabled() { } /** - * Escape special characters for regular expressions from a string. - * - * @param {string} string - * The string to sanitize. - * @returns {string} The sanitized string. - */ -export function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * Search a tab list for items that match the given query. - */ -export function searchTabList(query, tabList) { - const regex = RegExp(escapeRegExp(query), "i"); - return tabList.filter( - ({ title, url }) => regex.test(title) || regex.test(url) - ); -} - -/** * Get or create a logger, whose log-level is controlled by a pref * * @param {string} loggerName - Creating named loggers helps differentiate log messages from different diff --git a/browser/components/firefoxview/history.mjs b/browser/components/firefoxview/history.mjs index 478422d49b..4919f94e9c 100644 --- a/browser/components/firefoxview/history.mjs +++ b/browser/components/firefoxview/history.mjs @@ -15,13 +15,13 @@ import { 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, { + HistoryController: "resource:///modules/HistoryController.sys.mjs", ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", }); @@ -47,7 +47,7 @@ class HistoryInView extends ViewPage { this.cumulativeSearches = 0; } - controller = new HistoryController(this, { + controller = new lazy.HistoryController(this, { searchResultsLimit: SEARCH_RESULTS_LIMIT, }); @@ -57,7 +57,7 @@ class HistoryInView extends ViewPage { } this._started = true; - this.controller.updateAllHistoryItems(); + this.controller.updateCache(); this.toggleVisibilityInCardContainer(); } @@ -170,8 +170,8 @@ class HistoryInView extends ViewPage { this.recordContextMenuTelemetry("delete-from-history", e); } - async onChangeSortOption(e) { - await this.controller.onChangeSortOption(e); + onChangeSortOption(e) { + this.controller.onChangeSortOption(e); Services.telemetry.recordEvent( "firefoxview_next", "sort_history", @@ -184,8 +184,8 @@ class HistoryInView extends ViewPage { ); } - async onSearchQuery(e) { - await this.controller.onSearchQuery(e); + onSearchQuery(e) { + this.controller.onSearchQuery(e); this.cumulativeSearches = this.controller.searchQuery ? this.cumulativeSearches + 1 : 0; @@ -287,7 +287,7 @@ class HistoryInView extends ViewPage { get cardsTemplate() { if (this.controller.searchResults) { return this.#searchResultsTemplate(); - } else if (this.controller.allHistoryItems.size) { + } else if (!this.controller.isHistoryEmpty) { return this.#historyCardsTemplate(); } return this.#emptyMessageTemplate(); @@ -295,14 +295,11 @@ class HistoryInView extends ViewPage { #historyCardsTemplate() { let cardsTemplate = []; - if ( - this.controller.sortOption === "date" && - this.controller.historyMapByDate.length - ) { - this.controller.historyMapByDate.forEach(historyItem => { - if (historyItem.items.length) { + switch (this.controller.sortOption) { + case "date": + cardsTemplate = this.controller.historyVisits.map(historyItem => { let dateArg = JSON.stringify({ date: historyItem.items[0].time }); - cardsTemplate.push(html`<card-container> + return html`<card-container> <h3 slot="header" data-l10n-id=${historyItem.l10nId} @@ -316,19 +313,18 @@ 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} > ${this.panelListTemplate()} </fxview-tab-list> - </card-container>`); - } - }); - } else if (this.controller.historyMapBySite.length) { - this.controller.historyMapBySite.forEach(historyItem => { - if (historyItem.items.length) { - cardsTemplate.push(html`<card-container> + </card-container>`; + }); + break; + case "site": + cardsTemplate = this.controller.historyVisits.map(historyItem => { + return html`<card-container> <h3 slot="header" data-l10n-id="${ifDefined(historyItem.l10nId)}"> ${historyItem.domain} </h3> @@ -338,15 +334,15 @@ 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} > ${this.panelListTemplate()} </fxview-tab-list> - </card-container>`); - } - }); + </card-container>`; + }); + break; } return cardsTemplate; } @@ -420,7 +416,6 @@ class HistoryInView extends ViewPage { .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> @@ -491,12 +486,19 @@ class HistoryInView extends ViewPage { class="import-history-banner" hideHeader="true" ?hidden=${!this.shouldShowImportBanner()} + role="group" + aria-labelledby="header" + aria-describedby="description" > <div slot="main"> <div class="banner-text"> - <span data-l10n-id="firefoxview-import-history-header"></span> + <span + data-l10n-id="firefoxview-import-history-header" + id="header" + ></span> <span data-l10n-id="firefoxview-import-history-description" + id="description" ></span> </div> <div class="buttons"> @@ -518,7 +520,7 @@ class HistoryInView extends ViewPage { </div> <div class="show-all-history-footer" - ?hidden=${!this.controller.allHistoryItems.size} + ?hidden=${this.controller.isHistoryEmpty} > <button class="show-all-history-button" @@ -532,11 +534,6 @@ class HistoryInView extends ViewPage { willUpdate() { this.fullyUpdated = false; - if (this.controller.allHistoryItems.size) { - // onChangeSortOption() will update history data once it has been fetched - // from the API. - this.controller.createHistoryMaps(); - } } } customElements.define("view-history", HistoryInView); diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn index 8bf3597aa5..6eee0b8ffd 100644 --- a/browser/components/firefoxview/jar.mn +++ b/browser/components/firefoxview/jar.mn @@ -9,7 +9,6 @@ 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 @@ -19,6 +18,7 @@ browser.jar: content/browser/firefoxview/fxview-empty-state.css content/browser/firefoxview/fxview-empty-state.mjs content/browser/firefoxview/helpers.mjs + content/browser/firefoxview/search-helpers.mjs content/browser/firefoxview/fxview-search-textbox.css content/browser/firefoxview/fxview-search-textbox.mjs content/browser/firefoxview/fxview-tab-list.css diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs index fb84553e26..10845374bc 100644 --- a/browser/components/firefoxview/opentabs.mjs +++ b/browser/components/firefoxview/opentabs.mjs @@ -13,9 +13,9 @@ import { getLogger, isSearchEnabled, placeLinkOnClipboard, - searchTabList, MAX_TABS_FOR_RECENT_BROWSING, } from "./helpers.mjs"; +import { searchTabList } from "./search-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"; diff --git a/browser/components/firefoxview/recentlyclosed.mjs b/browser/components/firefoxview/recentlyclosed.mjs index 7efd8d09f2..6b3ed711c4 100644 --- a/browser/components/firefoxview/recentlyclosed.mjs +++ b/browser/components/firefoxview/recentlyclosed.mjs @@ -8,11 +8,8 @@ import { ifDefined, when, } from "chrome://global/content/vendor/lit.all.mjs"; -import { - isSearchEnabled, - searchTabList, - MAX_TABS_FOR_RECENT_BROWSING, -} from "./helpers.mjs"; +import { isSearchEnabled, MAX_TABS_FOR_RECENT_BROWSING } from "./helpers.mjs"; +import { searchTabList } from "./search-helpers.mjs"; import { ViewPage } from "./viewpage.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://browser/content/firefoxview/card-container.mjs"; diff --git a/browser/components/firefoxview/search-helpers.mjs b/browser/components/firefoxview/search-helpers.mjs new file mode 100644 index 0000000000..3a8c1e580c --- /dev/null +++ b/browser/components/firefoxview/search-helpers.mjs @@ -0,0 +1,24 @@ +/* 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/. */ + +/** + * Escape special characters for regular expressions from a string. + * + * @param {string} string + * The string to sanitize. + * @returns {string} The sanitized string. + */ +export function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Search a tab list for items that match the given query. + */ +export function searchTabList(query, tabList) { + const regex = RegExp(escapeRegExp(query), "i"); + return tabList.filter( + ({ title, url }) => regex.test(title) || regex.test(url) + ); +} diff --git a/browser/components/firefoxview/syncedtabs.mjs b/browser/components/firefoxview/syncedtabs.mjs index 1c65650c10..e71cce465e 100644 --- a/browser/components/firefoxview/syncedtabs.mjs +++ b/browser/components/firefoxview/syncedtabs.mjs @@ -170,7 +170,6 @@ class SyncedTabsInView extends ViewPage { data-l10n-id="${ifDefined(buttonLabel)}" data-action="${action}" @click=${e => this.controller.handleEvent(e)} - aria-details="empty-container" ></button> </fxview-empty-state> `; diff --git a/browser/components/firefoxview/tests/browser/browser.toml b/browser/components/firefoxview/tests/browser/browser.toml index db8b2ea25c..c9036286d7 100644 --- a/browser/components/firefoxview/tests/browser/browser.toml +++ b/browser/components/firefoxview/tests/browser/browser.toml @@ -43,9 +43,6 @@ skip-if = ["true"] # Bug 1869605 and # Bug 1870296 ["browser_history_firefoxview.js"] -["browser_notification_dot.js"] -skip-if = ["true"] # Bug 1851453 - ["browser_opentabs_cards.js"] ["browser_opentabs_changes.js"] diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_firefoxview.js index 1a51d61f42..00083d7c91 100644 --- a/browser/components/firefoxview/tests/browser/browser_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js @@ -31,20 +31,47 @@ add_task(async function test_aria_roles() { ); let recentlyClosedEmptyState = recentlyClosedComponent.emptyState; let descriptionEls = recentlyClosedEmptyState.descriptionEls; + const recentlyClosedCard = SpecialPowers.wrap( + recentlyClosedEmptyState + ).openOrClosedShadowRoot.querySelector("card-container"); is( - descriptionEls[1].querySelector("a").getAttribute("aria-details"), - "card-container", - "The link within the recently closed empty state has the expected 'aria-details' attribute." + recentlyClosedCard.getAttribute("aria-labelledby"), + "header", + "The recently closed empty state container has the expected 'aria-labelledby' attribute." + ); + is( + recentlyClosedCard.getAttribute("aria-describedby"), + "description", + "The recently closed empty state container has the expected 'aria-describedby' attribute." + ); + is( + recentlyClosedCard.getAttribute("role"), + "group", + "The recently closed empty state container has the expected 'role' attribute." ); let syncedTabsComponent = document.querySelector( "view-syncedtabs[slot=syncedtabs]" ); let syncedTabsEmptyState = syncedTabsComponent.emptyState; + const syncedCard = + SpecialPowers.wrap( + syncedTabsEmptyState + ).openOrClosedShadowRoot.querySelector("card-container"); + is( + syncedCard.getAttribute("aria-labelledby"), + "header", + "The synced tabs empty state container has the expected 'aria-labelledby' attribute." + ); + is( + syncedCard.getAttribute("aria-describedby"), + "description", + "The synced tabs empty state container has the expected 'aria-describedby' attribute." + ); is( - syncedTabsEmptyState.querySelector("button").getAttribute("aria-details"), - "empty-container", - "The button within the synced tabs empty state has the expected 'aria-details' attribute." + syncedCard.getAttribute("role"), + "group", + "The synced tabs empty state container has the expected 'role' attribute." ); // Test keyboard navigation from card-container summary diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js index bf53796ef7..e9502079d9 100644 --- a/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js @@ -32,7 +32,9 @@ add_task(async function test_max_render_count_on_win_resize() { await navigateToViewAndWait(document, "history"); let historyComponent = document.querySelector("view-history"); - let tabList = historyComponent.lists[0]; + let tabList = await TestUtils.waitForCondition( + () => historyComponent.lists[0] + ); let rootVirtualList = tabList.rootVirtualListEl; const initialHeight = window.outerHeight; diff --git a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js index 847ce4d9fd..0bbc009eab 100644 --- a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js @@ -57,15 +57,11 @@ function isElInViewport(element) { async function historyComponentReady(historyComponent, expectedHistoryItems) { await TestUtils.waitForCondition( - () => - [...historyComponent.controller.allHistoryItems.values()].reduce( - (acc, { length }) => acc + length, - 0 - ) === expectedHistoryItems, + () => historyComponent.controller.totalVisitsCount === expectedHistoryItems, "History component ready" ); - let expected = historyComponent.controller.historyMapByDate.length; + let expected = historyComponent.controller.historyVisits.length; let actual = historyComponent.cards.length; is(expected, actual, `Total number of cards should be ${expected}`); @@ -242,8 +238,7 @@ add_task(async function test_list_ordering() { await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); await sortHistoryTelemetry(sortHistoryEvent); - let expectedNumOfCards = - historyComponent.controller.historyMapBySite.length; + let expectedNumOfCards = historyComponent.controller.historyVisits.length; info(`Total number of cards should be ${expectedNumOfCards}`); await BrowserTestUtils.waitForMutationCondition( @@ -475,7 +470,7 @@ add_task(async function test_search_history() { EventUtils.sendString("Bogus Query", content); await TestUtils.waitForCondition(() => { const tabList = historyComponent.lists[0]; - return tabList?.shadowRoot.querySelector("fxview-empty-state"); + return tabList?.emptyState; }, "There are no matching search results."); info("Clear the search query."); @@ -485,7 +480,7 @@ add_task(async function test_search_history() { { childList: true, subtree: true }, () => historyComponent.cards.length === - historyComponent.controller.historyMapByDate.length + historyComponent.controller.historyVisits.length ); searchTextbox.blur(); @@ -494,7 +489,7 @@ add_task(async function test_search_history() { EventUtils.sendString("Bogus Query", content); await TestUtils.waitForCondition(() => { const tabList = historyComponent.lists[0]; - return tabList?.shadowRoot.querySelector("fxview-empty-state"); + return tabList?.emptyState; }, "There are no matching search results."); info("Clear the search query with keyboard."); @@ -514,11 +509,69 @@ add_task(async function test_search_history() { { childList: true, subtree: true }, () => historyComponent.cards.length === - historyComponent.controller.historyMapByDate.length + historyComponent.controller.historyVisits.length ); }); }); +add_task(async function test_search_ignores_stale_queries() { + await PlacesUtils.history.clear(); + const historyEntries = createHistoryEntries(); + await PlacesUtils.history.insertMany(historyEntries); + + let bogusQueryInProgress = false; + const searchDeferred = Promise.withResolvers(); + const realDatabase = await PlacesUtils.promiseLargeCacheDBConnection(); + const mockDatabase = { + executeCached: async (sql, options) => { + if (options.query === "Bogus Query") { + bogusQueryInProgress = true; + await searchDeferred.promise; + } + return realDatabase.executeCached(sql, options); + }, + interrupt: () => searchDeferred.reject(), + }; + const stub = sinon + .stub(PlacesUtils, "promiseLargeCacheDBConnection") + .resolves(mockDatabase); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "history"); + const historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + await historyComponentReady(historyComponent, historyEntries.length); + const searchTextbox = await TestUtils.waitForCondition( + () => historyComponent.searchTextbox, + "The search textbox is displayed." + ); + + info("Input a bogus search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("Bogus Query", content); + await TestUtils.waitForCondition(() => bogusQueryInProgress); + + info("Clear the bogus query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content); + await searchTextbox.updateComplete; + + info("Input a real search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("Example Domain 1", content); + await TestUtils.waitForCondition(() => { + const { rowEls } = historyComponent.lists[0]; + return rowEls.length === 1 && rowEls[0].mainEl.href === URLs[1]; + }, "There is one matching search result."); + searchDeferred.resolve(); + await TestUtils.waitForTick(); + const tabList = historyComponent.lists[0]; + ok(!tabList.emptyState, "Empty state should not be shown."); + }); + + stub.restore(); +}); + add_task(async function test_persist_collapse_card_after_view_change() { await PlacesUtils.history.clear(); await addHistoryItems(today); @@ -528,11 +581,7 @@ add_task(async function test_persist_collapse_card_after_view_change() { const historyComponent = document.querySelector("view-history"); historyComponent.profileAge = 8; await TestUtils.waitForCondition( - () => - [...historyComponent.controller.allHistoryItems.values()].reduce( - (acc, { length }) => acc + length, - 0 - ) === 4 + () => historyComponent.controller.totalVisitsCount === 4 ); let firstHistoryCard = historyComponent.cards[0]; ok( diff --git a/browser/components/firefoxview/tests/browser/browser_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js deleted file mode 100644 index 0fa747d40f..0000000000 --- a/browser/components/firefoxview/tests/browser/browser_notification_dot.js +++ /dev/null @@ -1,392 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - * http://creativecommons.org/publicdomain/zero/1.0/ */ - -const tabsList1 = syncedTabsData1[0].tabs; -const tabsList2 = syncedTabsData1[1].tabs; -const BADGE_TOP_RIGHT = "75% 25%"; - -const { SyncedTabs } = ChromeUtils.importESModule( - "resource://services-sync/SyncedTabs.sys.mjs" -); - -function setupRecentDeviceListMocks() { - const sandbox = sinon.createSandbox(); - sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ - { - id: 1, - name: "My desktop", - isCurrentDevice: true, - type: "desktop", - tabs: [], - }, - { - id: 2, - name: "My iphone", - type: "mobile", - tabs: [], - }, - ]); - - sandbox.stub(UIState, "get").returns({ - status: UIState.STATUS_SIGNED_IN, - syncEnabled: true, - }); - - return sandbox; -} - -function waitForWindowActive(win, active) { - info("Waiting for window activation"); - return Promise.all([ - BrowserTestUtils.waitForEvent(win, active ? "focus" : "blur"), - BrowserTestUtils.waitForEvent(win, active ? "activate" : "deactivate"), - ]); -} - -async function waitForNotificationBadgeToBeShowing(fxViewButton) { - info("Waiting for attention attribute to be set"); - await BrowserTestUtils.waitForMutationCondition( - fxViewButton, - { attributes: true }, - () => fxViewButton.hasAttribute("attention") - ); - return fxViewButton.hasAttribute("attention"); -} - -async function waitForNotificationBadgeToBeHidden(fxViewButton) { - info("Waiting for attention attribute to be removed"); - await BrowserTestUtils.waitForMutationCondition( - fxViewButton, - { attributes: true }, - () => !fxViewButton.hasAttribute("attention") - ); - return !fxViewButton.hasAttribute("attention"); -} - -async function clickFirefoxViewButton(win) { - await BrowserTestUtils.synthesizeMouseAtCenter( - "#firefox-view-button", - { type: "mousedown" }, - win.browsingContext - ); -} - -function getBackgroundPositionForElement(ele) { - let style = ele.ownerGlobal.getComputedStyle(ele); - return style.getPropertyValue("background-position"); -} - -let previousFetchTime = 0; - -async function resetSyncedTabsLastFetched() { - Services.prefs.clearUserPref("services.sync.lastTabFetch"); - previousFetchTime = 0; - await TestUtils.waitForTick(); -} - -async function initTabSync() { - let recentFetchTime = Math.floor(Date.now() / 1000); - // ensure we don't try to set the pref with the same value, which will not produce - // the expected pref change effects - while (recentFetchTime == previousFetchTime) { - await TestUtils.waitForTick(); - recentFetchTime = Math.floor(Date.now() / 1000); - } - Assert.greater( - recentFetchTime, - previousFetchTime, - "The new lastTabFetch value is greater than the previous" - ); - - info("initTabSync, updating lastFetch:" + recentFetchTime); - Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); - previousFetchTime = recentFetchTime; - await TestUtils.waitForTick(); -} - -add_setup(async function () { - await resetSyncedTabsLastFetched(); - await SpecialPowers.pushPrefEnv({ - set: [["browser.tabs.firefox-view.notify-for-tabs", true]], - }); - - // Clear any synced tabs from previous tests - FirefoxViewNotificationManager.syncedTabs = null; - Services.obs.notifyObservers( - null, - "firefoxview-notification-dot-update", - "false" - ); -}); - -/** - * Test that the notification badge will show and hide in the correct cases - */ -add_task(async function testNotificationDot() { - const sandbox = setupRecentDeviceListMocks(); - const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); - sandbox.spy(SyncedTabs, "syncTabs"); - - let win = await BrowserTestUtils.openNewBrowserWindow(); - let fxViewBtn = win.document.getElementById("firefox-view-button"); - ok(fxViewBtn, "Got the Firefox View button"); - - // Initiate a synced tabs update with new tabs - syncedTabsMock.returns(tabsList1); - await initTabSync(); - - ok( - BrowserTestUtils.isVisible(fxViewBtn), - "The Firefox View button is showing" - ); - - info( - "testNotificationDot, button is showing, badge should be initially hidden" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn), - "The notification badge is not showing initially" - ); - - // Initiate a synced tabs update with new tabs - syncedTabsMock.returns(tabsList2); - await initTabSync(); - - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn), - "The notification badge is showing after first tab sync" - ); - - // check that switching to the firefoxviewtab removes the badge - await clickFirefoxViewButton(win); - - info( - "testNotificationDot, after clicking the button, badge should become hidden" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn), - "The notification badge is not showing after going to Firefox View" - ); - - await BrowserTestUtils.waitForCondition(() => { - return SyncedTabs.syncTabs.calledOnce; - }); - - ok(SyncedTabs.syncTabs.calledOnce, "SyncedTabs.syncTabs() was called once"); - - syncedTabsMock.returns(tabsList1); - // Initiate a synced tabs update with new tabs - await initTabSync(); - - // The noti badge would show but we are on a Firefox View page so no need to show the noti badge - info( - "testNotificationDot, after updating the recent tabs, badge should be hidden" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn), - "The notification badge is not showing after tab sync while Firefox View is focused" - ); - - let newTab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); - syncedTabsMock.returns(tabsList2); - await initTabSync(); - - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn), - "The notification badge is showing after navigation to a new tab" - ); - - // check that switching back to the Firefox View tab removes the badge - await clickFirefoxViewButton(win); - - info( - "testNotificationDot, after switching back to fxview, badge should be hidden" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn), - "The notification badge is not showing after focusing the Firefox View tab" - ); - - await BrowserTestUtils.switchTab(win.gBrowser, newTab); - - // Initiate a synced tabs update with no new tabs - await initTabSync(); - - info( - "testNotificationDot, after switching back to fxview with no new tabs, badge should be hidden" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn), - "The notification badge is not showing after a tab sync with the same tabs" - ); - - await BrowserTestUtils.closeWindow(win); - - sandbox.restore(); -}); - -/** - * Tests the notification badge with multiple windows - */ -add_task(async function testNotificationDotOnMultipleWindows() { - const sandbox = setupRecentDeviceListMocks(); - - await resetSyncedTabsLastFetched(); - const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); - - // Create a new window - let win1 = await BrowserTestUtils.openNewBrowserWindow(); - await win1.delayedStartupPromise; - let fxViewBtn = win1.document.getElementById("firefox-view-button"); - ok(fxViewBtn, "Got the Firefox View button"); - - syncedTabsMock.returns(tabsList1); - // Initiate a synced tabs update - await initTabSync(); - - // Create another window - let win2 = await BrowserTestUtils.openNewBrowserWindow(); - await win2.delayedStartupPromise; - let fxViewBtn2 = win2.document.getElementById("firefox-view-button"); - - await clickFirefoxViewButton(win2); - - // Make sure the badge doesn't show on any window - info( - "testNotificationDotOnMultipleWindows, badge is initially hidden on window 1" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn), - "The notification badge is not showing in the inital window" - ); - info( - "testNotificationDotOnMultipleWindows, badge is initially hidden on window 2" - ); - ok( - await waitForNotificationBadgeToBeHidden(fxViewBtn2), - "The notification badge is not showing in the second window" - ); - - // Minimize the window. - win2.minimize(); - - await TestUtils.waitForCondition( - () => !win2.gBrowser.selectedBrowser.docShellIsActive, - "Waiting for docshell to be marked as inactive after minimizing the window" - ); - - syncedTabsMock.returns(tabsList2); - info("Initiate a synced tabs update with new tabs"); - await initTabSync(); - - // The badge will show because the View tab is minimized - // Make sure the badge shows on all windows - info( - "testNotificationDotOnMultipleWindows, after new synced tabs and minimized win2, badge is visible on window 1" - ); - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn), - "The notification badge is showing in the initial window" - ); - info( - "testNotificationDotOnMultipleWindows, after new synced tabs and minimized win2, badge is visible on window 2" - ); - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn2), - "The notification badge is showing in the second window" - ); - - win2.restore(); - await TestUtils.waitForCondition( - () => win2.gBrowser.selectedBrowser.docShellIsActive, - "Waiting for docshell to be marked as active after restoring the window" - ); - - await BrowserTestUtils.closeWindow(win1); - await BrowserTestUtils.closeWindow(win2); - - sandbox.restore(); -}); - -/** - * Tests the notification badge is in the correct spot and that the badge shows when opening a new window - * if another window is showing the badge - */ -add_task(async function testNotificationDotLocation() { - const sandbox = setupRecentDeviceListMocks(); - await resetSyncedTabsLastFetched(); - const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); - - syncedTabsMock.returns(tabsList1); - - let win1 = await BrowserTestUtils.openNewBrowserWindow(); - let fxViewBtn = win1.document.getElementById("firefox-view-button"); - ok(fxViewBtn, "Got the Firefox View button"); - - // Initiate a synced tabs update - await initTabSync(); - syncedTabsMock.returns(tabsList2); - // Initiate another synced tabs update - await initTabSync(); - - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn), - "The notification badge is showing initially" - ); - - // Create a new window - let win2 = await BrowserTestUtils.openNewBrowserWindow(); - await win2.delayedStartupPromise; - - // Make sure the badge is showing on the new window - let fxViewBtn2 = win2.document.getElementById("firefox-view-button"); - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn2), - "The notification badge is showing in the second window after opening" - ); - - // Make sure the badge is below and center now - isnot( - getBackgroundPositionForElement(fxViewBtn), - BADGE_TOP_RIGHT, - "The notification badge is not showing in the top right in the initial window" - ); - isnot( - getBackgroundPositionForElement(fxViewBtn2), - BADGE_TOP_RIGHT, - "The notification badge is not showing in the top right in the second window" - ); - - CustomizableUI.addWidgetToArea( - "firefox-view-button", - CustomizableUI.AREA_NAVBAR - ); - - // Make sure both windows still have the notification badge - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn), - "The notification badge is showing in the initial window" - ); - ok( - await waitForNotificationBadgeToBeShowing(fxViewBtn2), - "The notification badge is showing in the second window" - ); - - // Make sure the badge is in the top right now - is( - getBackgroundPositionForElement(fxViewBtn), - BADGE_TOP_RIGHT, - "The notification badge is showing in the top right in the initial window" - ); - is( - getBackgroundPositionForElement(fxViewBtn2), - BADGE_TOP_RIGHT, - "The notification badge is showing in the top right in the second window" - ); - - CustomizableUI.reset(); - await BrowserTestUtils.closeWindow(win1); - await BrowserTestUtils.closeWindow(win2); - - sandbox.restore(); -}); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js index 1bf387f578..872efd37a0 100644 --- a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js @@ -13,6 +13,11 @@ add_setup(async function () { registerCleanupFunction(async function () { await tearDown(gSandbox); }); + + // set tab sync false so we don't skip setup states + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); }); async function promiseTabListsUpdated({ tabLists }) { @@ -748,3 +753,158 @@ add_task(async function search_synced_tabs_recent_browsing() { await SpecialPowers.popPrefEnv(); await tearDown(sandbox); }); + +add_task(async function test_mobile_connected() { + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "mobile", + tabs: [], + }, + ], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await navigateToViewAndWait(document, "syncedtabs"); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "mobile" + ), + "A connected device is type:mobile" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +add_task(async function test_tablet_connected() { + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "tablet", + tabs: [], + }, + ], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await navigateToViewAndWait(document, "syncedtabs"); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "tablet" + ), + "A connected device is type:tablet" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +add_task(async function test_tab_sync_enabled() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "mobile", + tabs: [], + }, + ], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + // test initial state, with the pref not enabled + await navigateToViewAndWait(document, "syncedtabs"); + // test with the pref toggled on + Services.prefs.setBoolPref("services.sync.engine.tabs", true); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + ok(!syncedTabsComponent.emptyState, "No empty state is being displayed."); + + // reset and test clicking the action button + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.emptyState, + "The empty state is rendered." + ); + + const actionButton = syncedTabsComponent.emptyState?.querySelector( + "button[data-action=sync-tabs-disabled]" + ); + EventUtils.synthesizeMouseAtCenter(actionButton, {}, browser.contentWindow); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + await TestUtils.waitForCondition( + () => !syncedTabsComponent.emptyState, + "The empty state is rendered." + ); + + ok(true, "The empty state is no longer displayed when sync is enabled"); + ok( + Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "tab sync pref should be enabled after button click" + ); + }); + await tearDown(sandbox); +}); 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 52ddc277c7..abea8725ee 100644 --- a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html +++ b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html @@ -37,8 +37,8 @@ const { BrowserTestUtils } = ChromeUtils.importESModule( "resource://testing-common/BrowserTestUtils.sys.mjs" ); - const { FirefoxViewPlacesQuery } = ChromeUtils.importESModule( - "resource:///modules/firefox-view-places-query.sys.mjs" + const { PlacesQuery } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesQuery.sys.mjs" ); const { PlacesUtils } = ChromeUtils.importESModule( "resource://gre/modules/PlacesUtils.sys.mjs" @@ -52,7 +52,7 @@ const fxviewTabList = document.querySelector("fxview-tab-list"); let tabItems = []; - const placesQuery = new FirefoxViewPlacesQuery(); + const placesQuery = new PlacesQuery(); const URLs = [ "http://mochi.test:8888/browser/", @@ -106,7 +106,20 @@ }); await historyUpdated.promise; - fxviewTabList.tabItems = [...history.values()].flat(); + fxviewTabList.tabItems = Array.from(history.values()).flat().map(visit => ({ + ...visit, + time: visit.date.getTime(), + title: visit.title || visit.url, + icon: `page-icon:${visit.url}`, + primaryL10nId: "fxviewtabrow-tabs-list-tab", + primaryL10nArgs: JSON.stringify({ + targetURI: visit.url, + }), + secondaryL10nId: "fxviewtabrow-options-menu-button", + secondaryL10nArgs: JSON.stringify({ + tabTitle: visit.title || visit.url, + }), + })); await fxviewTabList.getUpdateComplete(); tabItems = Array.from(fxviewTabList.rowEls); diff --git a/browser/components/messagepreview/limelight.svg b/browser/components/messagepreview/limelight.svg index 938a17b3b2..2df3fffa5f 100644 --- a/browser/components/messagepreview/limelight.svg +++ b/browser/components/messagepreview/limelight.svg @@ -1,4 +1,4 @@ <!-- 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/. --> -<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="200pt" height="200pt" viewBox="0 0 200 200" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M118.083 16.167c-7.533 1.183-15.1 6.366-18.816 12.883l-.717 1.267-.283-.617c-1-2.117-4.55-6.317-5.85-6.933-1.517-.734-3.25.316-3.25 1.95 0 .9.183 1.183 1.75 2.7 2.966 2.883 4.883 7 5.666 12.166.134.917.25 2.184.25 2.834l-.016 1.166-1.117.5c-1.367.617-2.767 1.95-3.45 3.3-.367.7-.617.984-.917 1.05-.233.05-1.383.4-2.583.767-2.85.867-5.967 2.383-8.75 4.25-1.233.817-3.033 1.933-4 2.467-3.883 2.166-6.833 4.4-10.1 7.666C58.317 71.117 53.717 80.55 52.317 91.5c-.284 2.133-.267 7.7.016 10.25.634 5.733 2.234 11.133 4.817 16.2 1.85 3.65 3.467 6.05 6.533 9.733 5.784 6.934 10.917 15.7 13.267 22.634l.633 1.85 21.3-.034 21.3-.05.467-1.416c2.233-6.6 7.25-15.417 12.333-21.634.7-.85 1.884-2.316 2.65-3.25 5.45-6.683 8.834-15.033 9.817-24.083.333-3.217.217-8.5-.283-11.733-2.184-14.483-10.734-26.767-23.717-34.117-1-.55-2.233-1.35-2.75-1.733-1.4-1.083-4.233-2.75-6.033-3.567-1.467-.65-4.284-1.633-5.967-2.05-.617-.15-.75-.283-1.133-1.033-.6-1.183-2.05-2.65-3.234-3.283l-1-.534v-1.533a23.86 23.86 0 0 0-.433-4.333l-.1-.45h1.317c2.767 0 6.783-1.034 9.733-2.5 5.3-2.634 10.25-7.55 12.25-12.15.75-1.734 1.233-3.65 1.233-4.867v-.967l-1.116-.35c-1.3-.416-4.5-.583-6.134-.333zM83.533 68.35c.217.083.584.433.8.767.384.55.417.766.4 2.366 0 1.567-.066 1.934-.516 3.15-.867 2.25-2.034 4.017-4.45 6.684-3.384 3.716-4.967 7.4-4.967 11.516.017 1.35.1 2.284.333 3.184.5 1.95.1 3.316-1.367 4.6-2.1 1.85-4.15 1.15-5.616-1.867-1.134-2.333-1.384-3.583-1.384-6.833-.016-2.534.034-3.017.434-4.617 1.016-4 3.416-8.817 6.283-12.55 1.15-1.5 3.633-4.033 4.783-4.85 2.117-1.517 4.084-2.1 5.267-1.55zm44.317 39.267c1.183.616 1.85 1.55 1.95 2.783.1 1.15-.183 1.967-.95 2.817-.75.833-1.417 1.116-2.55 1.116-2.233-.016-3.817-1.783-3.6-4.033.167-1.733 1.783-3.1 3.65-3.133.333 0 .983.2 1.5.45zm4.117 8.916c2.116 1.084 2.483 4.084.683 5.717-.867.783-1.35.967-2.55.933-1.233-.016-2.183-.533-2.867-1.55-.416-.633-.483-.866-.483-1.983 0-1.1.067-1.35.45-1.95 1.083-1.617 2.983-2.083 4.767-1.167zm-9.834 3.5c1.417.667 2.2 2.4 1.8 3.95-.4 1.534-1.3 2.4-2.783 2.717-1.3.25-2.183-.033-3.167-1.017-1.416-1.4-1.533-3.366-.283-4.783 1.183-1.333 2.75-1.65 4.433-.867zm31.684-87.183c-.467.5-1.567 1.717-2.45 2.717s-2.484 2.766-3.55 3.933c-2.434 2.683-2.6 3.133-1.984 5.15.5 1.633.834 1.717 2.467.567 1.45-1.034 3.883-3.467 5.067-5.05 2.25-3.05 2.966-4.584 2.966-6.334 0-1.05-.333-1.533-1.216-1.766-.417-.1-.567 0-1.3.783zM40.25 33.95c-.05.133-.083.6-.083 1.033s-.05.85-.117.9c-.2.217-.55-.266-.667-.916-.116-.75-.483-1-.816-.55-.35.483-.3 2.683.1 3.95.416 1.383 1.216 2.583 3.333 5.05 4.317 5.016 10.717 11.833 11.35 12.1.65.25 1.2.016 2.617-1.167.833-.683 1.033-.967 1.033-1.333 0-.717-2.55-4.25-7.417-10.284-2.733-3.4-4.2-5.366-5.316-7.183-.95-1.533-1.134-1.667-2.634-1.75-1.016-.067-1.3-.033-1.383.15zm133.5 24.733c-.267.084-3.533.684-7.25 1.334-8.15 1.416-8.833 1.55-9.667 1.983-.7.367-1.05.767-1.35 1.633-.233.683-.066 1.9.334 2.383l.3.367 1.816-.283c1-.15 3.584-.417 5.734-.6 4.7-.4 6.15-.583 7.95-1 2.5-.583 3.6-1.35 4.383-3.05.767-1.683.767-1.717.117-2.367-.617-.616-1.284-.733-2.367-.4zm-148.917.434c-.983.166-2.516.766-2.666 1.05-.25.466-.2 1.383.1 1.8.133.2.7.666 1.25 1.033.533.383 2.733 2.05 4.866 3.717 5.517 4.35 7.6 5.916 8.384 6.283 1.35.667 2.7.333 3.633-.883.75-.984.633-1.267-1.1-2.667-.85-.683-2.45-2.1-3.55-3.133-5.767-5.417-7.8-6.884-9.917-7.184a4.07 4.07 0 0 0-1-.016zM161.967 85.65c-3.467.117-3.534.15-3.8 1.767-.35 2.15-.184 2.866.75 3.133.9.267 6.366.817 10.366 1.033 5.467.317 7.534.5 9.934.917 1.033.183 2.05.333 2.25.333.25 0 .683-.3 1.2-.833.45-.467.883-.833.983-.833.333 0 .183-.3-.567-1.1-.416-.434-.75-.85-.75-.934 0-.233.567-.15 1.134.2.733.45 1.033.434 1.033-.066 0-.9-2.65-2.817-4.517-3.267-1.65-.4-10.716-.567-18.016-.35zM23.5 90.433c-4.483.584-5.367.884-6.817 2.234-.65.616-.716.75-.633 1.283.183 1.1-.15 1.05 6.933 1.05 7.734 0 7.5.05 8.917-2.083.483-.734.55-1.284.183-1.634-.55-.566-6.366-1.133-8.583-.85zm54.2 76.434c.05 5.666.083 6.3.383 7.133.4 1.15 5.617 9 6.6 9.95.984.933 2.5 1.817 3.8 2.217 1 .316 1.584.333 10.367.333h9.3l1.3-.417c1.317-.416 2.683-1.2 3.7-2.15.717-.666 5.417-7.683 6.05-9.016.85-1.85.967-2.8.967-8.8v-5.45H77.65l.05 6.2z"/></svg> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="200pt" height="200pt" viewBox="0 0 200 200" fill="context-fill light-dark(black, white)" fill-opacity="context-fill-opacity"><path d="M118.083 16.167c-7.533 1.183-15.1 6.366-18.816 12.883l-.717 1.267-.283-.617c-1-2.117-4.55-6.317-5.85-6.933-1.517-.734-3.25.316-3.25 1.95 0 .9.183 1.183 1.75 2.7 2.966 2.883 4.883 7 5.666 12.166.134.917.25 2.184.25 2.834l-.016 1.166-1.117.5c-1.367.617-2.767 1.95-3.45 3.3-.367.7-.617.984-.917 1.05-.233.05-1.383.4-2.583.767-2.85.867-5.967 2.383-8.75 4.25-1.233.817-3.033 1.933-4 2.467-3.883 2.166-6.833 4.4-10.1 7.666C58.317 71.117 53.717 80.55 52.317 91.5c-.284 2.133-.267 7.7.016 10.25.634 5.733 2.234 11.133 4.817 16.2 1.85 3.65 3.467 6.05 6.533 9.733 5.784 6.934 10.917 15.7 13.267 22.634l.633 1.85 21.3-.034 21.3-.05.467-1.416c2.233-6.6 7.25-15.417 12.333-21.634.7-.85 1.884-2.316 2.65-3.25 5.45-6.683 8.834-15.033 9.817-24.083.333-3.217.217-8.5-.283-11.733-2.184-14.483-10.734-26.767-23.717-34.117-1-.55-2.233-1.35-2.75-1.733-1.4-1.083-4.233-2.75-6.033-3.567-1.467-.65-4.284-1.633-5.967-2.05-.617-.15-.75-.283-1.133-1.033-.6-1.183-2.05-2.65-3.234-3.283l-1-.534v-1.533a23.86 23.86 0 0 0-.433-4.333l-.1-.45h1.317c2.767 0 6.783-1.034 9.733-2.5 5.3-2.634 10.25-7.55 12.25-12.15.75-1.734 1.233-3.65 1.233-4.867v-.967l-1.116-.35c-1.3-.416-4.5-.583-6.134-.333zM83.533 68.35c.217.083.584.433.8.767.384.55.417.766.4 2.366 0 1.567-.066 1.934-.516 3.15-.867 2.25-2.034 4.017-4.45 6.684-3.384 3.716-4.967 7.4-4.967 11.516.017 1.35.1 2.284.333 3.184.5 1.95.1 3.316-1.367 4.6-2.1 1.85-4.15 1.15-5.616-1.867-1.134-2.333-1.384-3.583-1.384-6.833-.016-2.534.034-3.017.434-4.617 1.016-4 3.416-8.817 6.283-12.55 1.15-1.5 3.633-4.033 4.783-4.85 2.117-1.517 4.084-2.1 5.267-1.55zm44.317 39.267c1.183.616 1.85 1.55 1.95 2.783.1 1.15-.183 1.967-.95 2.817-.75.833-1.417 1.116-2.55 1.116-2.233-.016-3.817-1.783-3.6-4.033.167-1.733 1.783-3.1 3.65-3.133.333 0 .983.2 1.5.45zm4.117 8.916c2.116 1.084 2.483 4.084.683 5.717-.867.783-1.35.967-2.55.933-1.233-.016-2.183-.533-2.867-1.55-.416-.633-.483-.866-.483-1.983 0-1.1.067-1.35.45-1.95 1.083-1.617 2.983-2.083 4.767-1.167zm-9.834 3.5c1.417.667 2.2 2.4 1.8 3.95-.4 1.534-1.3 2.4-2.783 2.717-1.3.25-2.183-.033-3.167-1.017-1.416-1.4-1.533-3.366-.283-4.783 1.183-1.333 2.75-1.65 4.433-.867zm31.684-87.183c-.467.5-1.567 1.717-2.45 2.717s-2.484 2.766-3.55 3.933c-2.434 2.683-2.6 3.133-1.984 5.15.5 1.633.834 1.717 2.467.567 1.45-1.034 3.883-3.467 5.067-5.05 2.25-3.05 2.966-4.584 2.966-6.334 0-1.05-.333-1.533-1.216-1.766-.417-.1-.567 0-1.3.783zM40.25 33.95c-.05.133-.083.6-.083 1.033s-.05.85-.117.9c-.2.217-.55-.266-.667-.916-.116-.75-.483-1-.816-.55-.35.483-.3 2.683.1 3.95.416 1.383 1.216 2.583 3.333 5.05 4.317 5.016 10.717 11.833 11.35 12.1.65.25 1.2.016 2.617-1.167.833-.683 1.033-.967 1.033-1.333 0-.717-2.55-4.25-7.417-10.284-2.733-3.4-4.2-5.366-5.316-7.183-.95-1.533-1.134-1.667-2.634-1.75-1.016-.067-1.3-.033-1.383.15zm133.5 24.733c-.267.084-3.533.684-7.25 1.334-8.15 1.416-8.833 1.55-9.667 1.983-.7.367-1.05.767-1.35 1.633-.233.683-.066 1.9.334 2.383l.3.367 1.816-.283c1-.15 3.584-.417 5.734-.6 4.7-.4 6.15-.583 7.95-1 2.5-.583 3.6-1.35 4.383-3.05.767-1.683.767-1.717.117-2.367-.617-.616-1.284-.733-2.367-.4zm-148.917.434c-.983.166-2.516.766-2.666 1.05-.25.466-.2 1.383.1 1.8.133.2.7.666 1.25 1.033.533.383 2.733 2.05 4.866 3.717 5.517 4.35 7.6 5.916 8.384 6.283 1.35.667 2.7.333 3.633-.883.75-.984.633-1.267-1.1-2.667-.85-.683-2.45-2.1-3.55-3.133-5.767-5.417-7.8-6.884-9.917-7.184a4.07 4.07 0 0 0-1-.016zM161.967 85.65c-3.467.117-3.534.15-3.8 1.767-.35 2.15-.184 2.866.75 3.133.9.267 6.366.817 10.366 1.033 5.467.317 7.534.5 9.934.917 1.033.183 2.05.333 2.25.333.25 0 .683-.3 1.2-.833.45-.467.883-.833.983-.833.333 0 .183-.3-.567-1.1-.416-.434-.75-.85-.75-.934 0-.233.567-.15 1.134.2.733.45 1.033.434 1.033-.066 0-.9-2.65-2.817-4.517-3.267-1.65-.4-10.716-.567-18.016-.35zM23.5 90.433c-4.483.584-5.367.884-6.817 2.234-.65.616-.716.75-.633 1.283.183 1.1-.15 1.05 6.933 1.05 7.734 0 7.5.05 8.917-2.083.483-.734.55-1.284.183-1.634-.55-.566-6.366-1.133-8.583-.85zm54.2 76.434c.05 5.666.083 6.3.383 7.133.4 1.15 5.617 9 6.6 9.95.984.933 2.5 1.817 3.8 2.217 1 .316 1.584.333 10.367.333h9.3l1.3-.417c1.317-.416 2.683-1.2 3.7-2.15.717-.666 5.417-7.683 6.05-9.016.85-1.85.967-2.8.967-8.8v-5.45H77.65l.05 6.2z"/></svg> diff --git a/browser/components/migration/ChromeProfileMigrator.sys.mjs b/browser/components/migration/ChromeProfileMigrator.sys.mjs index e32417cd04..17aba35e8a 100644 --- a/browser/components/migration/ChromeProfileMigrator.sys.mjs +++ b/browser/components/migration/ChromeProfileMigrator.sys.mjs @@ -785,7 +785,8 @@ async function GetBookmarksResource(aProfileFolder, aBrowserKey) { } // Import Bookmark Favicons - MigrationUtils.insertManyFavicons(favicons); + MigrationUtils.insertManyFavicons(favicons).catch(console.error); + if (gotErrors) { throw new Error("The migration included errors."); } diff --git a/browser/components/migration/MSMigrationUtils.sys.mjs b/browser/components/migration/MSMigrationUtils.sys.mjs index 8d9a666e66..37dd69bf10 100644 --- a/browser/components/migration/MSMigrationUtils.sys.mjs +++ b/browser/components/migration/MSMigrationUtils.sys.mjs @@ -381,7 +381,7 @@ Bookmarks.prototype = { } await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid); - MigrationUtils.insertManyFavicons(favicons); + MigrationUtils.insertManyFavicons(favicons).catch(console.error); }, /** diff --git a/browser/components/migration/MigrationUtils.sys.mjs b/browser/components/migration/MigrationUtils.sys.mjs index cda3028cc4..90ba6a535e 100644 --- a/browser/components/migration/MigrationUtils.sys.mjs +++ b/browser/components/migration/MigrationUtils.sys.mjs @@ -870,36 +870,53 @@ class MigrationUtils { * Iterates through the favicons, sniffs for a mime type, * and uses the mime type to properly import the favicon. * + * Note: You may not want to await on the returned promise, especially if by + * doing so there's risk of interrupting the migration of more critical + * data (e.g. bookmarks). + * * @param {object[]} favicons * An array of Objects with these properties: * {Uint8Array} faviconData: The binary data of a favicon * {nsIURI} uri: The URI of the associated page */ - insertManyFavicons(favicons) { + async insertManyFavicons(favicons) { let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance( Ci.nsIContentSniffer ); + for (let faviconDataItem of favicons) { - let mimeType = sniffer.getMIMETypeFromContent( - null, - faviconDataItem.faviconData, - faviconDataItem.faviconData.length - ); + let dataURL; + + try { + // getMIMETypeFromContent throws error if could not get the mime type + // from the data. + let mimeType = sniffer.getMIMETypeFromContent( + null, + faviconDataItem.faviconData, + faviconDataItem.faviconData.length + ); + + dataURL = await new Promise((resolve, reject) => { + let buffer = new Uint8ClampedArray(faviconDataItem.faviconData); + let blob = new Blob([buffer], { type: mimeType }); + let reader = new FileReader(); + reader.addEventListener("load", () => resolve(reader.result)); + reader.addEventListener("error", reject); + reader.readAsDataURL(blob); + }); + } catch (e) { + // Even if error happens for favicon, continue the process. + console.warn(e); + continue; + } + let fakeFaviconURI = Services.io.newURI( "fake-favicon-uri:" + faviconDataItem.uri.spec ); - lazy.PlacesUtils.favicons.replaceFaviconData( - fakeFaviconURI, - faviconDataItem.faviconData, - mimeType - ); - lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + lazy.PlacesUtils.favicons.setFaviconForPage( faviconDataItem.uri, fakeFaviconURI, - true, - lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, - null, - Services.scriptSecurityManager.getSystemPrincipal() + Services.io.newURI(dataURL) ); } } diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs index c134c0869a..307edbd230 100644 --- a/browser/components/migration/SafariProfileMigrator.sys.mjs +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -98,7 +98,7 @@ Bookmarks.prototype = { let rows = await MigrationUtils.getRowsFromDBWithoutLocks( dbPath, "Safari favicons", - `SELECT I.uuid, I.url AS favicon_url, P.url + `SELECT I.uuid, I.url AS favicon_url, P.url FROM icon_info I INNER JOIN page_url P ON I.uuid = P.uuid;` ); @@ -253,7 +253,7 @@ Bookmarks.prototype = { parentGuid ); - MigrationUtils.insertManyFavicons(favicons); + MigrationUtils.insertManyFavicons(favicons).catch(console.error); }, /** diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs index 89872a1558..9d58fbe95f 100644 --- a/browser/components/migration/content/migration-wizard.mjs +++ b/browser/components/migration/content/migration-wizard.mjs @@ -333,6 +333,7 @@ export class MigrationWizard extends HTMLElement { this.#getPermissionsButton.addEventListener("click", this); this.#browserProfileSelector.addEventListener("click", this); + this.#browserProfileSelector.addEventListener("mousedown", this); this.#resourceTypeList = shadow.querySelector("#resource-type-list"); this.#resourceTypeList.addEventListener("change", this); @@ -1396,107 +1397,122 @@ export class MigrationWizard extends HTMLElement { } } + #handleClickEvent(event) { + if ( + event.target == this.#importButton || + event.target == this.#importFromFileButton + ) { + this.#doImport(); + } else if ( + event.target.classList.contains("cancel-close") || + event.target.classList.contains("finish-button") + ) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:Close", { bubbles: true }) + ); + } else if ( + event.currentTarget == this.#browserProfileSelectorList && + event.target != this.#browserProfileSelectorList + ) { + this.#onBrowserProfileSelectionChanged(event.target); + // If the user selected a file migration type from the selector, we'll + // help the user out by immediately starting the file migration flow, + // rather than waiting for them to click the "Select File". + if ( + event.target.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + this.#doImport(); + } + } else if (event.target == this.#safariPermissionButton) { + this.#requestSafariPermissions(); + } else if (event.currentTarget == this.#resourceSummary) { + this.#expandedDetails = true; + } else if (event.target == this.#chooseImportFromFile) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { + bubbles: true, + detail: { + allowOnlyFileMigrators: true, + }, + }) + ); + } else if (event.target == this.#safariPasswordImportSkipButton) { + // If the user chose to skip importing passwords from Safari, we + // programmatically uncheck the PASSWORDS resource type and re-request + // import. + let checkbox = this.#shadowRoot.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ).control; + checkbox.checked = false; + + // If there are no other checked checkboxes, go back to the selection + // screen. + let checked = this.#shadowRoot.querySelectorAll( + `label[data-resource-type] > input:checked` + ).length; + + if (!checked) { + this.requestState(); + } else { + this.#doImport(); + } + } else if (event.target == this.#safariPasswordImportSelectButton) { + this.#selectSafariPasswordFile(); + } else if (event.target == this.#extensionsSuccessLink) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:OpenAboutAddons", { + bubbles: true, + }) + ); + event.preventDefault(); + } else if (event.target == this.#getPermissionsButton) { + this.#getPermissions(); + } + } + + #handleChangeEvent(event) { + if (event.target == this.#browserProfileSelector) { + this.#onBrowserProfileSelectionChanged(); + } else if (event.target == this.#selectAllCheckbox) { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + for (let checkbox of checkboxes) { + checkbox.checked = this.#selectAllCheckbox.checked; + } + this.#displaySelectedResources(); + } else { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + + let allVisibleChecked = Array.from(checkboxes).every(checkbox => { + return checkbox.checked; + }); + + this.#selectAllCheckbox.checked = allVisibleChecked; + this.#displaySelectedResources(); + } + } + handleEvent(event) { + if ( + event.target == this.#browserProfileSelector && + (event.type == "mousedown" || + (event.type == "click" && + event.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD)) + ) { + this.#browserProfileSelectorList.toggle(event); + return; + } switch (event.type) { case "click": { - if ( - event.target == this.#importButton || - event.target == this.#importFromFileButton - ) { - this.#doImport(); - } else if ( - event.target.classList.contains("cancel-close") || - event.target.classList.contains("finish-button") - ) { - this.dispatchEvent( - new CustomEvent("MigrationWizard:Close", { bubbles: true }) - ); - } else if (event.target == this.#browserProfileSelector) { - this.#browserProfileSelectorList.show(event); - } else if ( - event.currentTarget == this.#browserProfileSelectorList && - event.target != this.#browserProfileSelectorList - ) { - this.#onBrowserProfileSelectionChanged(event.target); - // If the user selected a file migration type from the selector, we'll - // help the user out by immediately starting the file migration flow, - // rather than waiting for them to click the "Select File". - if ( - event.target.getAttribute("type") == - MigrationWizardConstants.MIGRATOR_TYPES.FILE - ) { - this.#doImport(); - } - } else if (event.target == this.#safariPermissionButton) { - this.#requestSafariPermissions(); - } else if (event.currentTarget == this.#resourceSummary) { - this.#expandedDetails = true; - } else if (event.target == this.#chooseImportFromFile) { - this.dispatchEvent( - new CustomEvent("MigrationWizard:RequestState", { - bubbles: true, - detail: { - allowOnlyFileMigrators: true, - }, - }) - ); - } else if (event.target == this.#safariPasswordImportSkipButton) { - // If the user chose to skip importing passwords from Safari, we - // programmatically uncheck the PASSWORDS resource type and re-request - // import. - let checkbox = this.#shadowRoot.querySelector( - `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` - ).control; - checkbox.checked = false; - - // If there are no other checked checkboxes, go back to the selection - // screen. - let checked = this.#shadowRoot.querySelectorAll( - `label[data-resource-type] > input:checked` - ).length; - - if (!checked) { - this.requestState(); - } else { - this.#doImport(); - } - } else if (event.target == this.#safariPasswordImportSelectButton) { - this.#selectSafariPasswordFile(); - } else if (event.target == this.#extensionsSuccessLink) { - this.dispatchEvent( - new CustomEvent("MigrationWizard:OpenAboutAddons", { - bubbles: true, - }) - ); - event.preventDefault(); - } else if (event.target == this.#getPermissionsButton) { - this.#getPermissions(); - } + this.#handleClickEvent(event); break; } case "change": { - if (event.target == this.#browserProfileSelector) { - this.#onBrowserProfileSelectionChanged(); - } else if (event.target == this.#selectAllCheckbox) { - let checkboxes = this.#shadowRoot.querySelectorAll( - 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' - ); - for (let checkbox of checkboxes) { - checkbox.checked = this.#selectAllCheckbox.checked; - } - this.#displaySelectedResources(); - } else { - let checkboxes = this.#shadowRoot.querySelectorAll( - 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' - ); - - let allVisibleChecked = Array.from(checkboxes).every(checkbox => { - return checkbox.checked; - }); - - this.#selectAllCheckbox.checked = allVisibleChecked; - this.#displaySelectedResources(); - } + this.#handleChangeEvent(event); break; } } diff --git a/browser/components/migration/tests/browser/browser_disabled_migrator.js b/browser/components/migration/tests/browser/browser_disabled_migrator.js index 782666f6a6..a9a6b3083c 100644 --- a/browser/components/migration/tests/browser/browser_disabled_migrator.js +++ b/browser/components/migration/tests/browser/browser_disabled_migrator.js @@ -17,7 +17,7 @@ add_task(async function test_enabled_migrator() { let wizard = dialog.querySelector("migration-wizard"); let shadow = wizard.openOrClosedShadowRoot; let selector = shadow.querySelector("#browser-profile-selector"); - selector.click(); + EventUtils.synthesizeMouseAtCenter(selector, {}, prefsWin); await new Promise(resolve => { shadow @@ -78,7 +78,7 @@ add_task(async function test_disabling_migrator() { let wizard = dialog.querySelector("migration-wizard"); let shadow = wizard.openOrClosedShadowRoot; let selector = shadow.querySelector("#browser-profile-selector"); - selector.click(); + EventUtils.synthesizeMouseAtCenter(selector, {}, prefsWin); await new Promise(resolve => { shadow diff --git a/browser/components/migration/tests/browser/browser_do_migration.js b/browser/components/migration/tests/browser/browser_do_migration.js index fab9641960..74454c0ab1 100644 --- a/browser/components/migration/tests/browser/browser_do_migration.js +++ b/browser/components/migration/tests/browser/browser_do_migration.js @@ -106,7 +106,7 @@ add_task(async function test_successful_migrations() { ); let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); - doneButton.click(); + EventUtils.synthesizeMouseAtCenter(doneButton, {}, prefsWin); await dialogClosed; assertQuantitiesShown(wizard, [ MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, diff --git a/browser/components/migration/tests/browser/browser_file_migration.js b/browser/components/migration/tests/browser/browser_file_migration.js index c73dfc4456..94f4ff2908 100644 --- a/browser/components/migration/tests/browser/browser_file_migration.js +++ b/browser/components/migration/tests/browser/browser_file_migration.js @@ -132,7 +132,7 @@ add_task(async function test_file_migration() { // Now select our DummyFileMigrator from the list. let selector = shadow.querySelector("#browser-profile-selector"); - selector.click(); + EventUtils.synthesizeMouseAtCenter(selector, {}, prefsWin); info("Waiting for panel-list shown"); await new Promise(resolve => { @@ -246,7 +246,7 @@ add_task(async function test_file_migration_error() { // Now select our DummyFileMigrator from the list. let selector = shadow.querySelector("#browser-profile-selector"); - selector.click(); + EventUtils.synthesizeMouseAtCenter(selector, {}, prefsWin); info("Waiting for panel-list shown"); await new Promise(resolve => { diff --git a/browser/components/migration/tests/browser/head.js b/browser/components/migration/tests/browser/head.js index d3d188a7e1..8824a50ee9 100644 --- a/browser/components/migration/tests/browser/head.js +++ b/browser/components/migration/tests/browser/head.js @@ -332,7 +332,7 @@ async function selectResourceTypesAndStartMigration( // First, select the InternalTestingProfileMigrator browser. let selector = shadow.querySelector("#browser-profile-selector"); - selector.click(); + EventUtils.synthesizeMouseAtCenter(selector, {}, wizard.ownerGlobal); await new Promise(resolve => { shadow diff --git a/browser/components/migration/tests/chrome/test_migration_wizard.html b/browser/components/migration/tests/chrome/test_migration_wizard.html index cc2d8a0363..43fd3ab931 100644 --- a/browser/components/migration/tests/chrome/test_migration_wizard.html +++ b/browser/components/migration/tests/chrome/test_migration_wizard.html @@ -147,7 +147,7 @@ // Test that the resource type checkboxes are shown or hidden depending on // which resourceTypes are included with the MigratorProfileInstance. for (let migratorInstance of MIGRATOR_PROFILE_INSTANCES) { - selector.click(); + synthesizeMouseAtCenter(selector, {}, gWiz.ownerGlobal); await new Promise(resolve => { gShadowRoot .querySelector("panel-list") @@ -248,7 +248,7 @@ ok(isHidden(preamble), "preamble should be hidden."); let selector = gShadowRoot.querySelector("#browser-profile-selector"); - selector.click(); + synthesizeMouseAtCenter(selector, {}, gWiz.ownerGlobal); await new Promise(resolve => { let panelList = gShadowRoot.querySelector("panel-list"); if (panelList) { diff --git a/browser/components/migration/tests/unit/head_migration.js b/browser/components/migration/tests/unit/head_migration.js index 9900f34232..9b056e6670 100644 --- a/browser/components/migration/tests/unit/head_migration.js +++ b/browser/components/migration/tests/unit/head_migration.js @@ -118,6 +118,34 @@ async function assertFavicons(pageURIs) { } /** + * Check the image data for favicon of given page uri. + * + * @param {string} pageURI + * The page URI to which the favicon belongs. + * @param {Array} expectedImageData + * Expected image data of the favicon. + * @param {string} expectedMimeType + * Expected mime type of the favicon. + */ +async function assertFavicon(pageURI, expectedImageData, expectedMimeType) { + let result = await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage( + Services.io.newURI(pageURI), + (faviconURI, dataLen, imageData, mimeType) => { + resolve({ faviconURI, dataLen, imageData, mimeType }); + } + ); + }); + Assert.ok(!!result, `Got favicon for ${pageURI}`); + Assert.equal( + result.imageData.join(","), + expectedImageData.join(","), + "Image data is correct" + ); + Assert.equal(result.mimeType, expectedMimeType, "Mime type is correct"); +} + +/** * Replaces a directory service entry with a given nsIFile. * * @param {string} key diff --git a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js index d115cda412..3c09869800 100644 --- a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js +++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js @@ -71,11 +71,13 @@ async function testBookmarks(migratorKey, subDirs) { ).path; await IOUtils.copy(sourcePath, target.path); - // Get page url for each favicon - let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + // Get page url and the image data for each favicon + let favicons = await MigrationUtils.getRowsFromDBWithoutLocks( sourcePath, "Chrome Bookmark Favicons", - `select page_url from icon_mapping` + `SELECT page_url, image_data FROM icon_mapping + INNER JOIN favicon_bitmaps ON (favicon_bitmaps.icon_id = icon_mapping.icon_id) + ` ); target.append("Bookmarks"); @@ -171,10 +173,14 @@ async function testBookmarks(migratorKey, subDirs) { "Telemetry reporting correct." ); Assert.ok(observerNotified, "The observer should be notified upon migration"); - let pageUrls = Array.from(faviconURIs, f => - Services.io.newURI(f.getResultByName("page_url")) - ); - await assertFavicons(pageUrls); + + for (const favicon of favicons) { + await assertFavicon( + favicon.getResultByName("page_url"), + favicon.getResultByName("image_data"), + "image/png" + ); + } } add_task(async function test_Chrome() { diff --git a/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js b/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js index 2578353e35..a22e6e1655 100644 --- a/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js +++ b/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js @@ -74,7 +74,7 @@ add_task(async function testHistoryImportStrangeEntries() { await PlacesUtils.history.clear(); let placesQuery = new PlacesQuery(); - let emptyHistory = await placesQuery.getHistory(); + let emptyHistory = await placesQuery.getHistory({ daysOld: Infinity }); Assert.equal(emptyHistory.size, 0, "Empty history should indeed be empty."); const EXPECTED_MIGRATED_SITES = 10; @@ -94,7 +94,10 @@ add_task(async function testHistoryImportStrangeEntries() { let migrator = await MigrationUtils.getMigrator("safari"); await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); - let migratedHistory = await placesQuery.getHistory({ sortBy: "site" }); + let migratedHistory = await placesQuery.getHistory({ + daysOld: Infinity, + sortBy: "site", + }); let siteCount = migratedHistory.size; let visitCount = 0; for (let [, visits] of migratedHistory) { diff --git a/browser/components/moz.build b/browser/components/moz.build index 0f91b90fb0..1909ee2786 100644 --- a/browser/components/moz.build +++ b/browser/components/moz.build @@ -67,6 +67,7 @@ DIRS += [ "tabpreview", "tabunloader", "textrecognition", + "topsites", "translations", "uitour", "urlbar", diff --git a/browser/components/newtab/AboutNewTabService.sys.mjs b/browser/components/newtab/AboutNewTabService.sys.mjs index 73502fcb4f..37adc25f6e 100644 --- a/browser/components/newtab/AboutNewTabService.sys.mjs +++ b/browser/components/newtab/AboutNewTabService.sys.mjs @@ -109,7 +109,11 @@ export const AboutHomeStartupCacheChild = { ); } - if (!lazy.NimbusFeatures.abouthomecache.getVariable("enabled")) { + if ( + !Services.prefs.getBoolPref( + "browser.startup.homepage.abouthome_cache.enabled" + ) + ) { return; } diff --git a/browser/components/newtab/common/Actions.mjs b/browser/components/newtab/common/Actions.mjs index 7273d80220..a86a1d1e81 100644 --- a/browser/components/newtab/common/Actions.mjs +++ b/browser/components/newtab/common/Actions.mjs @@ -161,6 +161,11 @@ for (const type of [ "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WALLPAPERS_SET", + "WALLPAPER_CLICK", + "WEATHER_IMPRESSION", + "WEATHER_LOAD_ERROR", + "WEATHER_OPEN_PROVIDER_URL", + "WEATHER_UPDATE", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { diff --git a/browser/components/newtab/common/Reducers.sys.mjs b/browser/components/newtab/common/Reducers.sys.mjs index 326217538d..edd3668434 100644 --- a/browser/components/newtab/common/Reducers.sys.mjs +++ b/browser/components/newtab/common/Reducers.sys.mjs @@ -104,6 +104,12 @@ export const INITIAL_STATE = { Wallpapers: { wallpaperList: [], }, + Weather: { + // do we have the data from WeatherFeed yet? + initialized: false, + suggestions: [], + lastUpdated: null, + }, }; function App(prevState = INITIAL_STATE.App, action) { @@ -853,6 +859,20 @@ function Wallpapers(prevState = INITIAL_STATE.Wallpapers, action) { } } +function Weather(prevState = INITIAL_STATE.Weather, action) { + switch (action.type) { + case at.WEATHER_UPDATE: + return { + ...prevState, + suggestions: action.data.suggestions, + lastUpdated: action.data.date, + initialized: true, + }; + default: + return prevState; + } +} + export const reducers = { TopSites, App, @@ -865,4 +885,5 @@ export const reducers = { DiscoveryStream, Search, Wallpapers, + Weather, }; diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx index 1738f8f51a..61722fd418 100644 --- a/browser/components/newtab/content-src/components/Base/Base.jsx +++ b/browser/components/newtab/content-src/components/Base/Base.jsx @@ -12,6 +12,7 @@ import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMen import React from "react"; import { Search } from "content-src/components/Search/Search"; import { Sections } from "content-src/components/Sections/Sections"; +import { Weather } from "content-src/components/Weather/Weather"; const VISIBLE = "visible"; const VISIBILITY_CHANGE_EVENT = "visibilitychange"; @@ -235,7 +236,7 @@ export class BaseContent extends React.PureComponent { return ( <p className={`wallpaper-attribution`} - key={name} + key={name.string} data-l10n-id="newtab-wallpaper-attribution" data-l10n-args={JSON.stringify({ author_string: name.string, @@ -278,6 +279,15 @@ export class BaseContent extends React.PureComponent { `--newtab-wallpaper-dark`, `url(${darkWallpaper?.wallpaperUrl || ""})` ); + + // Add helper class to body if user has a wallpaper selected + if (lightWallpaper) { + global.document?.body.classList.add("hasWallpaperLight"); + } + + if (darkWallpaper) { + global.document?.body.classList.add("hasWallpaperDark"); + } } } @@ -290,6 +300,7 @@ export class BaseContent extends React.PureComponent { const activeWallpaper = prefs[`newtabWallpapers.wallpaper-${this.state.colorMode}`]; const wallpapersEnabled = prefs["newtabWallpapers.enabled"]; + const weatherEnabled = prefs.showWeather; const { pocketConfig } = prefs; @@ -322,10 +333,12 @@ export class BaseContent extends React.PureComponent { showSponsoredPocketEnabled: prefs.showSponsored, showRecentSavesEnabled: prefs.showRecentSaves, topSitesRowsCount: prefs.topSitesRows, + weatherEnabled: prefs.showWeather, }; const pocketRegion = prefs["feeds.system.topstories"]; const mayHaveSponsoredStories = prefs["system.showSponsored"]; + const mayHaveWeather = prefs["system.showWeather"]; const { mayHaveSponsoredTopSites } = prefs; const outerClassName = [ @@ -358,6 +371,7 @@ export class BaseContent extends React.PureComponent { pocketRegion={pocketRegion} mayHaveSponsoredTopSites={mayHaveSponsoredTopSites} mayHaveSponsoredStories={mayHaveSponsoredStories} + mayHaveWeather={mayHaveWeather} spocMessageVariant={spocMessageVariant} showing={customizeMenuVisible} /> @@ -393,6 +407,13 @@ export class BaseContent extends React.PureComponent { <ConfirmDialog /> {wallpapersEnabled && this.renderWallpaperAttribution()} </main> + <aside> + {weatherEnabled && ( + <ErrorBoundary> + <Weather /> + </ErrorBoundary> + )} + </aside> </div> </div> ); @@ -410,4 +431,5 @@ export const Base = connect(state => ({ DiscoveryStream: state.DiscoveryStream, Search: state.Search, Wallpapers: state.Wallpapers, + Weather: state.Weather, }))(_Base); 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 1dd13fc965..494d506da9 100644 --- a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -28,7 +28,7 @@ export class ContentSection extends React.PureComponent { } onPreferenceSelect(e) { - // eventSource: TOP_SITES | TOP_STORIES | HIGHLIGHTS + // eventSource: TOP_SITES | TOP_STORIES | HIGHLIGHTS | WEATHER const { preference, eventSource } = e.target.dataset; let value; if (e.target.nodeName === "SELECT") { @@ -97,6 +97,7 @@ export class ContentSection extends React.PureComponent { pocketRegion, mayHaveSponsoredStories, mayHaveRecentSaves, + mayHaveWeather, openPreferences, spocMessageVariant, wallpapersEnabled, @@ -107,6 +108,7 @@ export class ContentSection extends React.PureComponent { topSitesEnabled, pocketEnabled, highlightsEnabled, + weatherEnabled, showSponsoredTopSitesEnabled, showSponsoredPocketEnabled, showRecentSavesEnabled, @@ -269,6 +271,22 @@ export class ContentSection extends React.PureComponent { </label> </div> + {mayHaveWeather && ( + <div id="weather-section" className="section"> + <label className="switch"> + <moz-toggle + id="weather-toggle" + pressed={weatherEnabled || null} + onToggle={this.onPreferenceSelect} + data-preference="showWeather" + data-eventSource="WEATHER" + data-l10n-id="newtab-custom-weather-toggle" + data-l10n-attrs="label, description" + /> + </label> + </div> + )} + {pocketRegion && mayHaveSponsoredStories && spocMessageVariant === "variant-c" && ( diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx index f1c723fed2..035e84af58 100644 --- a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx @@ -55,12 +55,14 @@ export class _CustomizeMenu extends React.PureComponent { role="dialog" data-l10n-id="newtab-personalize-dialog-label" > - <button - onClick={() => this.props.onClose()} - className="close-button" - data-l10n-id="newtab-custom-close-button" - ref={c => (this.closeButton = c)} - /> + <div className="close-button-wrapper"> + <button + onClick={() => this.props.onClose()} + className="close-button" + data-l10n-id="newtab-custom-close-button" + ref={c => (this.closeButton = c)} + /> + </div> <ContentSection openPreferences={this.props.openPreferences} setPref={this.props.setPref} @@ -71,6 +73,7 @@ export class _CustomizeMenu extends React.PureComponent { mayHaveSponsoredTopSites={this.props.mayHaveSponsoredTopSites} mayHaveSponsoredStories={this.props.mayHaveSponsoredStories} mayHaveRecentSaves={this.props.DiscoveryStream.recentSavesEnabled} + mayHaveWeather={this.props.mayHaveWeather} spocMessageVariant={this.props.spocMessageVariant} dispatch={this.props.dispatch} /> diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss index c20da5ce50..403a62a50f 100644 --- a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss +++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss @@ -47,7 +47,8 @@ inset-block: 0; inset-inline-end: 0; z-index: 1001; - padding: 16px; + padding-block: 0 var(--space-large); + padding-inline: var(--space-large); overflow: auto; transform: translateX(435px); visibility: hidden; @@ -85,9 +86,17 @@ box-shadow: $shadow-large; } + .close-button-wrapper { + position: sticky; + top: 0; + padding-block-start: var(--space-large); + background-color: var(--newtab-background-color-secondary); + z-index: 1; + } + .close-button { margin-inline-start: auto; - margin-bottom: 28px; + margin-inline-end: var(--space-large); white-space: nowrap; display: block; background-color: var(--newtab-element-secondary-color); @@ -117,7 +126,7 @@ grid-template-columns: 1fr; grid-template-rows: repeat(4, auto); grid-row-gap: 32px; - padding: 0 16px; + padding: var(--space-large); .wallpapers-section h2 { font-size: inherit; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx index 8b9d64dfc1..79d453a7c9 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx @@ -126,8 +126,11 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { this.systemTick = this.systemTick.bind(this); this.syncRemoteSettings = this.syncRemoteSettings.bind(this); this.onStoryToggle = this.onStoryToggle.bind(this); + this.handleWeatherSubmit = this.handleWeatherSubmit.bind(this); + this.handleWeatherUpdate = this.handleWeatherUpdate.bind(this); this.state = { toggledStories: {}, + weatherQuery: "", }; } @@ -182,6 +185,16 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS); } + handleWeatherUpdate(e) { + this.setState({ weatherQuery: e.target.value || "" }); + } + + handleWeatherSubmit(e) { + e.preventDefault(); + const { weatherQuery } = this.state; + this.props.dispatch(ac.SetPref("weather.query", weatherQuery)); + } + renderComponent(width, component) { return ( <table> @@ -200,6 +213,46 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { ); } + renderWeatherData() { + const { suggestions } = this.props.state.Weather; + let weatherTable; + if (suggestions) { + weatherTable = ( + <div className="weather-section"> + <form onSubmit={this.handleWeatherSubmit}> + <label htmlFor="weather-query">Weather query</label> + <input + type="text" + min="3" + max="10" + id="weather-query" + onChange={this.handleWeatherUpdate} + value={this.weatherQuery} + /> + <button type="submit">Submit</button> + </form> + <table> + <tbody> + {suggestions.map(suggestion => ( + <tr className="message-item" key={suggestion.city_name}> + <td className="message-id"> + <span> + {suggestion.city_name} <br /> + </span> + </td> + <td className="message-summary"> + <pre>{JSON.stringify(suggestion, null, 2)}</pre> + </td> + </tr> + ))} + </tbody> + </table> + </div> + ); + } + return weatherTable; + } + renderFeedData(url) { const { feeds } = this.props.state.DiscoveryStream; const feed = feeds.data[url].data; @@ -376,6 +429,8 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { {this.renderSpocs()} <h3>Feeds Data</h3> {this.renderFeedsData()} + <h3>Weather Data</h3> + {this.renderWeatherData()} </div> ); } @@ -412,6 +467,7 @@ export class DiscoveryStreamAdminInner extends React.PureComponent { state={{ DiscoveryStream: this.props.DiscoveryStream, Personalization: this.props.Personalization, + Weather: this.props.Weather, }} otherPrefs={this.props.Prefs.values} dispatch={this.props.dispatch} @@ -500,4 +556,5 @@ export const DiscoveryStreamAdmin = connect(state => ({ DiscoveryStream: state.DiscoveryStream, Personalization: state.Personalization, Prefs: state.Prefs, + Weather: state.Weather, }))(_DiscoveryStreamAdmin); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss index a01227dd3d..dcad97c917 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss @@ -334,4 +334,16 @@ } } } + + .weather-section { + margin-block-end: 24px; + + form { + display: flex; + + label { + margin-inline-end: 12px; + } + } + } } 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 b3d965530d..461d54899f 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -468,57 +468,34 @@ export class _DSCard extends React.PureComponent { dispatch={this.props.dispatch} spocMessageVariant={this.props.spocMessageVariant} /> - {saveToPocketCard && ( - <div className="card-stp-button-hover-background"> - <div className="card-stp-button-position-wrapper"> - {!this.props.flightId && stpButton()} - <DSLinkMenu - id={this.props.id} - index={this.props.pos} - dispatch={this.props.dispatch} - url={this.props.url} - title={this.props.title} - source={source} - type={this.props.type} - pocket_id={this.props.pocket_id} - shim={this.props.shim} - bookmarkGuid={this.props.bookmarkGuid} - flightId={ - !this.props.is_collection ? this.props.flightId : undefined - } - showPrivacyInfo={!!this.props.flightId} - onMenuUpdate={this.onMenuUpdate} - onMenuShow={this.onMenuShow} - saveToPocketCard={saveToPocketCard} - pocket_button_enabled={pocketButtonEnabled} - isRecentSave={isRecentSave} - /> - </div> + + <div className="card-stp-button-hover-background"> + <div className="card-stp-button-position-wrapper"> + {saveToPocketCard && <>{!this.props.flightId && stpButton()}</>} + + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + saveToPocketCard={saveToPocketCard} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> </div> - )} - {!saveToPocketCard && ( - <DSLinkMenu - id={this.props.id} - index={this.props.pos} - dispatch={this.props.dispatch} - url={this.props.url} - title={this.props.title} - source={source} - type={this.props.type} - pocket_id={this.props.pocket_id} - shim={this.props.shim} - bookmarkGuid={this.props.bookmarkGuid} - flightId={ - !this.props.is_collection ? this.props.flightId : undefined - } - showPrivacyInfo={!!this.props.flightId} - hostRef={this.contextMenuButtonHostRef} - onMenuUpdate={this.onMenuUpdate} - onMenuShow={this.onMenuShow} - pocket_button_enabled={pocketButtonEnabled} - isRecentSave={isRecentSave} - /> - )} + </div> </article> ); } diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss index 9004e609df..e5ac19b553 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss @@ -54,12 +54,6 @@ $ds-card-image-gradient-solid: rgba(0, 0, 0, 100%); fill: $white; } - .context-menu-button { - position: static; - transition: none; - border-radius: 3px; - } - .context-menu-position-container { position: relative; } @@ -83,6 +77,10 @@ $ds-card-image-gradient-solid: rgba(0, 0, 0, 100%); padding: 6px; white-space: nowrap; color: $white; + + &:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); + } } button, @@ -95,6 +93,28 @@ $ds-card-image-gradient-solid: rgba(0, 0, 0, 100%); } } + // Override note: The colors set here are intentionally static + // due to transparency issues over images. + .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + background-color: var(--newtab-button-static-background); + + &:hover { + background-color: var(--newtab-button-static-hover-background); + + &:active { + background-color: var(--newtab-button-static-active-background); + } + } + + &:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); + background-color: var(--newtab-button-static-focus-background); + } + } + &.last-item { .card-stp-button-hover-background { .context-menu { diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss index 54b39524d8..f726e936b9 100644 --- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss @@ -37,6 +37,24 @@ a { text-decoration: none; } + + // Override note: The colors set here are intentionally static + // due to transparency issues over images. + .context-menu-button { + background-color: var(--newtab-button-static-background); + + &:hover { + background-color: var(--newtab-button-static-hover-background); + + &:active { + background-color: var(--newtab-button-static-active-background); + } + } + + &:focus-visible { + background-color: var(--newtab-button-static-focus-background); + } + } } } } diff --git a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss index 4e4019513d..09a20c235d 100644 --- a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss +++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss @@ -126,12 +126,23 @@ $letter-fallback-color: $white; } } + // Necessary for when navigating by a keyboard, having the context + // menu open should display the "…" button. This style is a clone + // of the `:active` state for `.context-menu-button` + &.active { + .context-menu-button { + opacity: 1; + background-color: var(--newtab-button-active-background); + } + } + .context-menu-button { background-image: url('chrome://global/skin/icons/more.svg'); + background-color: var(--newtab-button-background); border: 0; border-radius: 4px; cursor: pointer; - fill: var(--newtab-text-primary-color); + fill: var(--newtab-button-text); -moz-context-properties: fill; height: 20px; width: 20px; @@ -141,11 +152,18 @@ $letter-fallback-color: $white; top: -20px; transition: opacity 200ms; - &:is(:active, :focus) { - outline: 0; + &:hover { + background-color: var(--newtab-button-hover-background); + + &:active { + background-color: var(--newtab-button-active-background); + } + } + + &:focus-visible { + background-color: var(--newtab-button-focus-background); + border-color: var(--newtab-button-focus-border); opacity: 1; - background-color: var(--newtab-element-hover-color); - fill: var(--newtab-primary-action-background); } } diff --git a/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx b/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx index 0b51a146f5..6fcd4b3a15 100644 --- a/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx +++ b/browser/components/newtab/content-src/components/WallpapersSection/WallpapersSection.jsx @@ -4,6 +4,7 @@ import React from "react"; import { connect } from "react-redux"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; export class _WallpapersSection extends React.PureComponent { constructor(props) { @@ -25,6 +26,10 @@ export class _WallpapersSection extends React.PureComponent { const prefs = this.props.Prefs.values; const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, id); + this.handleUserEvent({ + selected_wallpaper: id, + hadPreviousWallpaper: !!this.props.activeWallpaper, + }); // bug 1892095 if ( prefs["newtabWallpapers.wallpaper-dark"] === "" && @@ -50,6 +55,20 @@ export class _WallpapersSection extends React.PureComponent { handleReset() { const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, ""); + this.handleUserEvent({ + selected_wallpaper: "none", + hadPreviousWallpaper: !!this.props.activeWallpaper, + }); + } + + // Record user interaction when changing wallpaper and reseting wallpaper to default + handleUserEvent(data) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.WALLPAPER_CLICK, + data, + }) + ); } render() { diff --git a/browser/components/newtab/content-src/components/Weather/Weather.jsx b/browser/components/newtab/content-src/components/Weather/Weather.jsx new file mode 100644 index 0000000000..9273f9a4bd --- /dev/null +++ b/browser/components/newtab/content-src/components/Weather/Weather.jsx @@ -0,0 +1,350 @@ +/* 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 { connect } from "react-redux"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +export class _Weather extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + contextMenuKeyboard: false, + showContextMenu: false, + url: "https://example.com", + impressionSeen: false, + errorSeen: false, + }; + this.setImpressionRef = element => { + this.impressionElement = element; + }; + this.setErrorRef = element => { + this.errorElement = element; + }; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onUpdate = this.onUpdate.bind(this); + this.onProviderClick = this.onProviderClick.bind(this); + } + + componentDidMount() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + // Setup the impression observer once the page is visible. + this.setImpressionObservers(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + // Setup the impression observer once the page is visible. + this.setImpressionObservers(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillUnmount() { + // Remove observers on unmount + if (this.observer && this.impressionElement) { + this.observer.unobserve(this.impressionElement); + } + if (this.observer && this.errorElement) { + this.observer.unobserve(this.errorElement); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + setImpressionObservers() { + if (this.impressionElement) { + this.observer = new IntersectionObserver(this.onImpression.bind(this)); + this.observer.observe(this.impressionElement); + } + if (this.errorElement) { + this.observer = new IntersectionObserver(this.onError.bind(this)); + this.observer.observe(this.errorElement); + } + } + + onImpression(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + if (this.impressionElement) { + this.observer.unobserve(this.impressionElement); + } + + this.props.dispatch( + ac.OnlyToMain({ + type: at.WEATHER_IMPRESSION, + }) + ); + + // Stop observing since element has been seen + this.setState({ + impressionSeen: true, + }); + } + } + } + + onError(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + if (this.errorElement) { + this.observer.unobserve(this.errorElement); + } + + this.props.dispatch( + ac.OnlyToMain({ + type: at.WEATHER_LOAD_ERROR, + }) + ); + + // Stop observing since element has been seen + this.setState({ + errorSeen: true, + }); + } + } + } + + openContextMenu(isKeyBoard) { + if (this.props.onUpdate) { + this.props.onUpdate(true); + } + this.setState({ + showContextMenu: true, + contextMenuKeyboard: isKeyBoard, + }); + } + + onClick(event) { + event.preventDefault(); + this.openContextMenu(false, event); + } + + onKeyDown(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openContextMenu(true, event); + } + } + + onUpdate(showContextMenu) { + if (this.props.onUpdate) { + this.props.onUpdate(showContextMenu); + } + this.setState({ showContextMenu }); + } + + onProviderClick() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.WEATHER_OPEN_PROVIDER_URL, + data: { + source: "WEATHER", + }, + }) + ); + } + + render() { + // Check if weather should be rendered + const isWeatherEnabled = this.props.Prefs.values["system.showWeather"]; + + if (!isWeatherEnabled || !this.props.Weather.initialized) { + return false; + } + + const { showContextMenu } = this.state; + + const WEATHER_SUGGESTION = this.props.Weather.suggestions?.[0]; + + const { + className, + index, + dispatch, + eventSource, + shouldSendImpressionStats, + } = this.props; + const { props } = this; + const isContextMenuOpen = this.state.activeCard === index; + + const outerClassName = [ + "weather", + className, + isContextMenuOpen && "active", + props.placeholder && "placeholder", + ] + .filter(v => v) + .join(" "); + + const showDetailedView = + this.props.Prefs.values["weather.display"] === "detailed"; + + // Note: The temperature units/display options will become secondary menu items + const WEATHER_SOURCE_CONTEXT_MENU_OPTIONS = [ + ...(this.props.Prefs.values["weather.locationSearchEnabled"] + ? ["ChangeWeatherLocation"] + : []), + ...(this.props.Prefs.values["weather.temperatureUnits"] === "f" + ? ["ChangeTempUnitCelsius"] + : ["ChangeTempUnitFahrenheit"]), + ...(this.props.Prefs.values["weather.display"] === "simple" + ? ["ChangeWeatherDisplayDetailed"] + : ["ChangeWeatherDisplaySimple"]), + "HideWeather", + "OpenLearnMoreURL", + ]; + + // Only return the widget if we have data. Otherwise, show error state + if (WEATHER_SUGGESTION) { + return ( + <div ref={this.setImpressionRef} className={outerClassName}> + <div className="weatherCard"> + <a + data-l10n-id="newtab-weather-see-forecast" + data-l10n-args='{"provider": "AccuWeather"}' + href={WEATHER_SUGGESTION.forecast.url} + className="weatherInfoLink" + onClick={this.onProviderClick} + > + <div className="weatherIconCol"> + <span + className={`weatherIcon iconId${WEATHER_SUGGESTION.current_conditions.icon_id}`} + /> + </div> + <div className="weatherText"> + <div className="weatherForecastRow"> + <span className="weatherTemperature"> + { + WEATHER_SUGGESTION.current_conditions.temperature[ + this.props.Prefs.values["weather.temperatureUnits"] + ] + } + °{this.props.Prefs.values["weather.temperatureUnits"]} + </span> + </div> + <div className="weatherCityRow"> + <span className="weatherCity"> + {WEATHER_SUGGESTION.city_name} + </span> + </div> + {showDetailedView ? ( + <div className="weatherDetailedSummaryRow"> + <div className="weatherHighLowTemps"> + {/* Low Forecasted Temperature */} + <span> + { + WEATHER_SUGGESTION.forecast.high[ + this.props.Prefs.values["weather.temperatureUnits"] + ] + } + ° + {this.props.Prefs.values["weather.temperatureUnits"]} + </span> + {/* Spacer / Bullet */} + <span>•</span> + {/* Low Forecasted Temperature */} + <span> + { + WEATHER_SUGGESTION.forecast.low[ + this.props.Prefs.values["weather.temperatureUnits"] + ] + } + ° + {this.props.Prefs.values["weather.temperatureUnits"]} + </span> + </div> + <span className="weatherTextSummary"> + {WEATHER_SUGGESTION.current_conditions.summary} + </span> + </div> + ) : null} + </div> + </a> + <div className="weatherButtonContextMenuWrapper"> + <button + aria-haspopup="true" + onKeyDown={this.onKeyDown} + onClick={this.onClick} + data-l10n-id="newtab-menu-section-tooltip" + className="weatherButtonContextMenu" + > + {showContextMenu ? ( + <LinkMenu + dispatch={dispatch} + index={index} + source={eventSource} + onUpdate={this.onUpdate} + options={WEATHER_SOURCE_CONTEXT_MENU_OPTIONS} + site={{ + url: "https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page", + }} + link="https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page" + shouldSendImpressionStats={shouldSendImpressionStats} + /> + ) : null} + </button> + </div> + </div> + <span + data-l10n-id="newtab-weather-sponsored" + data-l10n-args='{"provider": "AccuWeather"}' + className="weatherSponsorText" + ></span> + </div> + ); + } + + return ( + <div ref={this.setErrorRef} className={outerClassName}> + <div className="weatherNotAvailable"> + <span className="icon icon-small-spacer icon-info-critical" />{" "} + <span data-l10n-id="newtab-weather-error-not-available"></span> + </div> + </div> + ); + } +} + +export const Weather = connect(state => ({ + Weather: state.Weather, + Prefs: state.Prefs, + IntersectionObserver: globalThis.IntersectionObserver, + document: globalThis.document, +}))(_Weather); diff --git a/browser/components/newtab/content-src/components/Weather/_Weather.scss b/browser/components/newtab/content-src/components/Weather/_Weather.scss new file mode 100644 index 0000000000..0616530f98 --- /dev/null +++ b/browser/components/newtab/content-src/components/Weather/_Weather.scss @@ -0,0 +1,393 @@ +// Custom font sizing for weather widget +:root { + --newtab-weather-content-font-size: 11px; + --newtab-weather-sponsor-font-size: 8px; +} + +.weather { + font-size: var(--font-size-root); + position: absolute; + left: var(--space-xlarge); + top: var(--space-xlarge); + z-index: 1; +} + +// Unavailable / Error State +.weatherNotAvailable { + font-size: var(--newtab-weather-content-font-size); + color: var(--text-color-error); + display: flex; + align-items: center; + + .icon { + fill: var(--icon-color-critical); + -moz-context-properties: fill; + } +} + +.weatherCard { + margin-block-end: var(--space-xsmall); + display: flex; + flex-wrap: nowrap; + align-items: stretch; + border-radius: var(--border-radius-medium); + overflow: hidden; + + &:hover, &:focus-within { + ~ .weatherSponsorText { + visibility: visible; + } + } + + &:focus-within { + overflow: visible; + } + + &:hover { + box-shadow: var(--box-shadow-10); + background: var(--background-color-box); + } + + a { + color: var(--text-color); + } + +} + +.weatherSponsorText { + visibility: hidden; + font-size: var(--newtab-weather-sponsor-font-size); + color: var(--text-color-deemphasized); +} + +.weatherInfoLink, .weatherButtonContextMenuWrapper { + appearance: none; + background-color: var(--background-color-ghost); + border: 0; + padding: var(--space-small); + cursor: pointer; + + &:hover { + // TODO: Add Wallpaper Background Color Fix + background-color: var(--button-background-color-ghost-hover); + + &::after { + background-color: transparent + } + + &:active { + // TODO: Add Wallpaper Background Color Fix + background-color: var(--button-background-color-ghost-active); + } + } + + &:focus-visible { + outline: var(--focus-outline); + } + + // Contrast fix for users who have wallpapers set + .hasWallpaperDark & { + @media (prefers-color-scheme: dark) { + // TODO: Replace with token + background-color: rgba(35, 34, 43, 70%); + + &:hover { + background-color: var(--newtab-button-static-hover-background); + } + + &:hover:active { + background-color: var(--newtab-button-static-active-background); + } + } + + @media (prefers-contrast) and (prefers-color-scheme: dark) { + background-color: var(--background-color-box); + } + } + + .hasWallpaperLight & { + @media (prefers-color-scheme: light) { + // TODO: Replace with token + background-color: rgba(255, 255, 255, 70%); + + &:hover { + background-color: var(--newtab-button-static-hover-background); + } + + &:hover:active { + background-color: var(--newtab-button-static-active-background); + } + } + + @media (prefers-contrast) and (prefers-color-scheme: light) { + background-color: var(--background-color-box); + } + } + +} + +.weatherInfoLink { + display: flex; + gap: var(--space-medium); + padding: var(--space-small) var(--space-medium); + border-radius: var(--border-radius-medium) 0 0 var(--border-radius-medium); + text-decoration: none; + color: var(--text-color);; + min-width: 130px; + max-width: 190px; + text-overflow: ellipsis; + + @media(min-width: $break-point-medium) { + min-width: unset; + } + + &:hover ~.weatherButtonContextMenuWrapper { + &::after { + background-color: transparent + } + } + + &:focus-visible { + border-radius: var(--border-radius-medium); + + ~ .weatherButtonContextMenuWrapper { + &::after { + background-color: transparent + } + } + } +} + +.weatherButtonContextMenuWrapper { + position: relative; + cursor: pointer; + border-radius: 0 var(--border-radius-medium) var(--border-radius-medium) 0; + display: flex; + align-items: stretch; + width: 50px; + padding: 0; + + &::after { + content: ''; + left: 0; + top: 10px; + height: calc(100% - 20px); + width: 1px; + background-color: var(--newtab-button-static-background); + display: block; + position: absolute; + z-index: 0; + } + + @media (prefers-color-scheme: dark) { + &::after { + background-color: var(--color-gray-70); + } + } + + &:hover { + &::after { + background-color: transparent + } + } + + &:focus-visible { + border-radius: var(--border-radius-medium); + + &::after { + background-color: transparent + } + } +} + +.weatherButtonContextMenu { + background-image: url('chrome://global/skin/icons/more.svg'); + background-repeat: no-repeat; + background-size: var(--size-item-small) auto; + background-position: center; + background-color: transparent; + cursor: pointer; + fill: var(--icon-color); + -moz-context-properties: fill; + width: 100%; + height: 100%; + border: 0; + appearance: none; + min-width: var(--size-item-large); +} + +.weatherText { + height: min-content; +} + +.weatherCityRow, .weatherForecastRow, .weatherDetailedSummaryRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-small); +} + +.weatherForecastRow { + text-transform: uppercase; + font-weight: var(--font-weight-bold); +} + +.weatherCityRow { + color: var(--text-color-deemphasized); +} + +.weatherCity { + text-overflow: ellipsis; + font-size: var(--font-size-small); +} + +// Add additional margin if detailed summary is in view +.weatherCityRow + .weatherDetailedSummaryRow { + margin-block-start: var(--space-xsmall); +} + +.weatherDetailedSummaryRow { + font-size: var(--newtab-weather-content-font-size); + gap: var(--space-large); +} + +.weatherHighLowTemps { + display: flex; + gap: var(--space-xxsmall); + text-transform: uppercase; + word-spacing: var(--space-xxsmall); +} + +.weatherTextSummary { + text-align: center; + max-width: 90px; +} + +.weatherTemperature { + font-size: var(--font-size-large); +} + +// Weather Symbol Icons +.weatherIconCol { + width: var(--size-item-large); + height: var(--size-item-large); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + align-self: center; +} + +.weatherIcon { + width: var(--size-item-large); + height: auto; + vertical-align: middle; + + @media (prefers-contrast) { + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + } + + &.iconId1 { + content: url('chrome://browser/skin/weather/sunny.svg'); + // height: var(--size-item-large); + } + + &.iconId2 { + content: url('chrome://browser/skin/weather/mostly-sunny.svg'); + // height: var(--size-item-large); + } + + &:is(.iconId3, .iconId4, .iconId6) { + content: url('chrome://browser/skin/weather/partly-sunny.svg'); + // height: var(--size-item-large); + } + + &.iconId5 { + content: url('chrome://browser/skin/weather/hazy-sunshine.svg'); + // height: var(--size-item-large); + } + + &:is(.iconId7, .iconId8) { + content: url('chrome://browser/skin/weather/cloudy.svg'); + } + + &.iconId11 { + content: url('chrome://browser/skin/weather/fog.svg'); + } + + &.iconId12 { + content: url('chrome://browser/skin/weather/showers.svg'); + } + + &:is(.iconId13, .iconId14) { + content: url('chrome://browser/skin/weather/mostly-cloudy-with-showers.svg'); + // height: var(--size-item-large); + } + + &.iconId15 { + content: url('chrome://browser/skin/weather/thunderstorms.svg'); + } + + &:is(.iconId16, .iconId17) { + content: url('chrome://browser/skin/weather/mostly-cloudy-with-thunderstorms.svg'); + } + + &.iconId18 { + content: url('chrome://browser/skin/weather/rain.svg'); + } + + &:is(.iconId19, .iconId20, .iconId25) { + content: url('chrome://browser/skin/weather/flurries.svg'); + } + + &.iconId21 { + content: url('chrome://browser/skin/weather/partly-sunny-with-flurries.svg'); + } + + &:is(.iconId22, .iconId23) { + content: url('chrome://browser/skin/weather/snow.svg'); + } + + &:is(.iconId24, .iconId31) { + content: url('chrome://browser/skin/weather/ice.svg'); + } + + &:is(.iconId26, .iconId29) { + content: url('chrome://browser/skin/weather/freezing-rain.svg'); + } + + &.iconId30 { + content: url('chrome://browser/skin/weather/hot.svg'); + } + + &.iconId32 { + content: url('chrome://browser/skin/weather/windy.svg'); + } + + &.iconId33 { + content: url('chrome://browser/skin/weather/night-clear.svg'); + } + + &:is(.iconId34, .iconId35, .iconId36, .iconId38) { + content: url('chrome://browser/skin/weather/night-mostly-clear.svg'); + } + + &.iconId37 { + content: url('chrome://browser/skin/weather/night-hazy-moonlight.svg'); + } + + &:is(.iconId39, .iconId40) { + content: url('chrome://browser/skin/weather/night-partly-cloudy-with-showers.svg'); + height: var(--size-item-large); + } + + &:is(.iconId41, .iconId42) { + content: url('chrome://browser/skin/weather/night-partly-cloudy-with-thunderstorms.svg'); + } + + &:is(.iconId43, .iconId44) { + content: url('chrome://browser/skin/weather/night-mostly-cloudy-with-flurries.svg'); + } +} diff --git a/browser/components/newtab/content-src/lib/link-menu-options.mjs b/browser/components/newtab/content-src/lib/link-menu-options.mjs index f10a5e34c6..23dcf8b050 100644 --- a/browser/components/newtab/content-src/lib/link-menu-options.mjs +++ b/browser/components/newtab/content-src/lib/link-menu-options.mjs @@ -306,4 +306,68 @@ export const LinkMenuOptions = { : LinkMenuOptions.EmptyItem(), OpenInPrivateWindow: (site, index, eventSource, isEnabled) => isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(), + ChangeWeatherLocation: () => ({ + id: "newtab-weather-menu-change-location", + action: ac.OnlyToMain({ + type: at.CHANGE_WEATHER_LOCATION, + data: { url: "https://mozilla.org" }, + }), + }), + ChangeWeatherDisplaySimple: () => ({ + id: "newtab-weather-menu-change-weather-display-simple", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "weather.display", + value: "simple", + }, + }), + }), + ChangeWeatherDisplayDetailed: () => ({ + id: "newtab-weather-menu-change-weather-display-detailed", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "weather.display", + value: "detailed", + }, + }), + }), + ChangeTempUnitFahrenheit: () => ({ + id: "newtab-weather-menu-change-temperature-units-fahrenheit", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "weather.temperatureUnits", + value: "f", + }, + }), + }), + ChangeTempUnitCelsius: () => ({ + id: "newtab-weather-menu-change-temperature-units-celsius", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "weather.temperatureUnits", + value: "c", + }, + }), + }), + HideWeather: () => ({ + id: "newtab-weather-menu-hide-weather", + action: ac.OnlyToMain({ + type: at.SET_PREF, + data: { + name: "showWeather", + value: false, + }, + }), + }), + OpenLearnMoreURL: site => ({ + id: "newtab-weather-menu-learn-more", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: site.url }, + }), + }), }; diff --git a/browser/components/newtab/content-src/styles/_activity-stream.scss b/browser/components/newtab/content-src/styles/_activity-stream.scss index d2e66667b2..580f35416e 100644 --- a/browser/components/newtab/content-src/styles/_activity-stream.scss +++ b/browser/components/newtab/content-src/styles/_activity-stream.scss @@ -149,6 +149,7 @@ input { @import '../components/ConfirmDialog/ConfirmDialog'; @import '../components/CustomizeMenu/CustomizeMenu'; @import '../components/WallpapersSection/WallpapersSection'; +@import '../components/Weather/Weather'; @import '../components/Card/Card'; @import '../components/CollapsibleSection/CollapsibleSection'; @import '../components/DiscoveryStreamAdmin/DiscoveryStreamAdmin'; diff --git a/browser/components/newtab/content-src/styles/_icons.scss b/browser/components/newtab/content-src/styles/_icons.scss index 8be97ad9ae..39879b2b44 100644 --- a/browser/components/newtab/content-src/styles/_icons.scss +++ b/browser/components/newtab/content-src/styles/_icons.scss @@ -4,7 +4,7 @@ background-size: $icon-size; -moz-context-properties: fill; display: inline-block; - color: var(--newtab-text-primary-color); + color: var(--icon-color); fill: currentColor; height: $icon-size; vertical-align: middle; @@ -70,6 +70,10 @@ background-image: url('chrome://global/skin/icons/info.svg'); } + &.icon-info-critical { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-info-critical-16.svg'); + } + &.icon-help { background-image: url('chrome://global/skin/icons/help.svg'); } @@ -167,6 +171,10 @@ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg'); } + &.icon-weather { + background-image: url('chrome://browser/skin/weather/sunny.svg'); + } + &.icon-highlights { background-image: url('chrome://global/skin/icons/highlights.svg'); } diff --git a/browser/components/newtab/content-src/styles/_theme.scss b/browser/components/newtab/content-src/styles/_theme.scss index 6b097ae93e..78b54f4f8e 100644 --- a/browser/components/newtab/content-src/styles/_theme.scss +++ b/browser/components/newtab/content-src/styles/_theme.scss @@ -38,6 +38,21 @@ $shadow-image-inset: inset 0 0 0 0.5px $black-15; --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #{$black}); --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #{$black}); + // --newtab-button-*-color is used on all new page card/top site options buttons + --newtab-button-background: var(--button-background-color); + --newtab-button-focus-background: var(--newtab-button-background); + --newtab-button-focus-border: var(--focus-outline-color); + --newtab-button-hover-background: var(--button-background-color-hover); + --newtab-button-active-background: var(--button-background-color-active); + --newtab-button-text: var(--button-text-color); + + // --newtab-button-static*-color is used on pocket cards and require a + // static color unit due to transparency issues with `color-mix` + --newtab-button-static-background: #F0F0F4; + --newtab-button-static-focus-background: var(--newtab-button-static-background); + --newtab-button-static-hover-background: #E0E0E6; + --newtab-button-static-active-background: #CFCFD8; + // --newtab-element-secondary*-color is used when an element needs to be set // off from the secondary background color. --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); @@ -87,6 +102,12 @@ $shadow-image-inset: inset 0 0 0 0.5px $black-15; --newtab-primary-element-text-color: #{$primary-text-color-dark}; --newtab-wordmark-color: #{$newtab-wordmark-darktheme-color}; --newtab-status-success: #{$status-dark-green}; + + // --newtab-button-static*-color is used on pocket cards and require a + // static color unit due to transparency issues with `color-mix` + --newtab-button-static-background: #2B2A33; + --newtab-button-static-hover-background: #52525E; + --newtab-button-static-active-background: #5B5B66; } } diff --git a/browser/components/newtab/content-src/styles/_variables.scss b/browser/components/newtab/content-src/styles/_variables.scss index 9fd0083841..43672c7796 100644 --- a/browser/components/newtab/content-src/styles/_variables.scss +++ b/browser/components/newtab/content-src/styles/_variables.scss @@ -157,14 +157,17 @@ $customize-menu-border-tint: 1px solid rgba(0, 0, 0, 15%); @mixin context-menu-button { .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url('chrome://global/skin/icons/more.svg'); background-position: 55%; - border: $border-primary; + border: 0; + outline: $border-primary; + outline-width: 0; border-radius: 100%; box-shadow: $context-menu-button-boxshadow; cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: $context-menu-button-size; inset-inline-end: math.div(-$context-menu-button-size, 2); opacity: 0; @@ -175,10 +178,26 @@ $customize-menu-border-tint: 1px solid rgba(0, 0, 0, 15%); transition-property: transform, opacity; width: $context-menu-button-size; - &:is(:active, :focus) { + &:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } + + &:is(:hover) { + background-color: var(--newtab-button-hover-background); + } + + &:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; + } + + &:is(:active) { + background-color: var(--newtab-button-active-background); + } + + } } diff --git a/browser/components/newtab/css/activity-stream-linux.css b/browser/components/newtab/css/activity-stream-linux.css index 131ffac535..248de6cf21 100644 --- a/browser/components/newtab/css/activity-stream-linux.css +++ b/browser/components/newtab/css/activity-stream-linux.css @@ -42,6 +42,16 @@ input { --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #000); --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #000); + --newtab-button-background: var(--button-background-color); + --newtab-button-focus-background: var(--newtab-button-background); + --newtab-button-focus-border: var(--focus-outline-color); + --newtab-button-hover-background: var(--button-background-color-hover); + --newtab-button-active-background: var(--button-background-color-active); + --newtab-button-text: var(--button-text-color); + --newtab-button-static-background: #F0F0F4; + --newtab-button-static-focus-background: var(--newtab-button-static-background); + --newtab-button-static-hover-background: #E0E0E6; + --newtab-button-static-active-background: #CFCFD8; --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); @@ -79,6 +89,9 @@ input { --newtab-primary-element-text-color: #2b2a33; --newtab-wordmark-color: #fbfbfe; --newtab-status-success: #7C6; + --newtab-button-static-background: #2B2A33; + --newtab-button-static-hover-background: #52525E; + --newtab-button-static-active-background: #5B5B66; } @media (prefers-contrast) { @@ -92,7 +105,7 @@ input { background-size: 16px; -moz-context-properties: fill; display: inline-block; - color: var(--newtab-text-primary-color); + color: var(--icon-color); fill: currentColor; height: 16px; vertical-align: middle; @@ -142,6 +155,9 @@ input { .icon.icon-info { background-image: url("chrome://global/skin/icons/info.svg"); } +.icon.icon-info-critical { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-info-critical-16.svg"); +} .icon.icon-help { background-image: url("chrome://global/skin/icons/help.svg"); } @@ -222,6 +238,9 @@ input { .icon.icon-webextension { background-image: url("chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"); } +.icon.icon-weather { + background-image: url("chrome://browser/skin/weather/sunny.svg"); +} .icon.icon-highlights { background-image: url("chrome://global/skin/icons/highlights.svg"); } @@ -659,12 +678,17 @@ main section { .top-site-outer:is(:hover) .context-menu-button { opacity: 1; } +.top-site-outer.active .context-menu-button { + opacity: 1; + background-color: var(--newtab-button-active-background); +} .top-site-outer .context-menu-button { background-image: url("chrome://global/skin/icons/more.svg"); + background-color: var(--newtab-button-background); border: 0; border-radius: 4px; cursor: pointer; - fill: var(--newtab-text-primary-color); + fill: var(--newtab-button-text); -moz-context-properties: fill; height: 20px; width: 20px; @@ -674,11 +698,16 @@ main section { top: -20px; transition: opacity 200ms; } -.top-site-outer .context-menu-button:is(:active, :focus) { - outline: 0; +.top-site-outer .context-menu-button:hover { + background-color: var(--newtab-button-hover-background); +} +.top-site-outer .context-menu-button:hover:active { + background-color: var(--newtab-button-active-background); +} +.top-site-outer .context-menu-button:focus-visible { + background-color: var(--newtab-button-focus-background); + border-color: var(--newtab-button-focus-border); opacity: 1; - background-color: var(--newtab-element-hover-color); - fill: var(--newtab-primary-action-background); } .top-site-outer .tile { border-radius: 8px; @@ -1675,7 +1704,8 @@ main section { inset-block: 0; inset-inline-end: 0; z-index: 1001; - padding: 16px; + padding-block: 0 var(--space-large); + padding-inline: var(--space-large); overflow: auto; transform: translateX(435px); visibility: hidden; @@ -1702,9 +1732,16 @@ main section { .customize-menu.customize-animate-exit-active { box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.2); } +.customize-menu .close-button-wrapper { + position: sticky; + top: 0; + padding-block-start: var(--space-large); + background-color: var(--newtab-background-color-secondary); + z-index: 1; +} .customize-menu .close-button { margin-inline-start: auto; - margin-bottom: 28px; + margin-inline-end: var(--space-large); white-space: nowrap; display: block; background-color: var(--newtab-element-secondary-color); @@ -1731,7 +1768,7 @@ main section { grid-template-columns: 1fr; grid-template-rows: repeat(4, auto); grid-row-gap: 32px; - padding: 0 16px; + padding: var(--space-large); } .home-section .wallpapers-section h2 { font-size: inherit; @@ -1978,6 +2015,333 @@ main section { text-decoration: none; } +:root { + --newtab-weather-content-font-size: 11px; + --newtab-weather-sponsor-font-size: 8px; +} + +.weather { + font-size: var(--font-size-root); + position: absolute; + left: var(--space-xlarge); + top: var(--space-xlarge); + z-index: 1; +} + +.weatherNotAvailable { + font-size: var(--newtab-weather-content-font-size); + color: var(--text-color-error); + display: flex; + align-items: center; +} +.weatherNotAvailable .icon { + fill: var(--icon-color-critical); + -moz-context-properties: fill; +} + +.weatherCard { + margin-block-end: var(--space-xsmall); + display: flex; + flex-wrap: nowrap; + align-items: stretch; + border-radius: var(--border-radius-medium); + overflow: hidden; +} +.weatherCard:hover ~ .weatherSponsorText, .weatherCard:focus-within ~ .weatherSponsorText { + visibility: visible; +} +.weatherCard:focus-within { + overflow: visible; +} +.weatherCard:hover { + box-shadow: var(--box-shadow-10); + background: var(--background-color-box); +} +.weatherCard a { + color: var(--text-color); +} + +.weatherSponsorText { + visibility: hidden; + font-size: var(--newtab-weather-sponsor-font-size); + color: var(--text-color-deemphasized); +} + +.weatherInfoLink, .weatherButtonContextMenuWrapper { + appearance: none; + background-color: var(--background-color-ghost); + border: 0; + padding: var(--space-small); + cursor: pointer; +} +.weatherInfoLink:hover, .weatherButtonContextMenuWrapper:hover { + background-color: var(--button-background-color-ghost-hover); +} +.weatherInfoLink:hover::after, .weatherButtonContextMenuWrapper:hover::after { + background-color: transparent; +} +.weatherInfoLink:hover:active, .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--button-background-color-ghost-active); +} +.weatherInfoLink:focus-visible, .weatherButtonContextMenuWrapper:focus-visible { + outline: var(--focus-outline); +} +@media (prefers-color-scheme: dark) { + .hasWallpaperDark .weatherInfoLink, .hasWallpaperDark .weatherButtonContextMenuWrapper { + background-color: rgba(35, 34, 43, 0.7); + } + .hasWallpaperDark .weatherInfoLink:hover, .hasWallpaperDark .weatherButtonContextMenuWrapper:hover { + background-color: var(--newtab-button-static-hover-background); + } + .hasWallpaperDark .weatherInfoLink:hover:active, .hasWallpaperDark .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--newtab-button-static-active-background); + } +} +@media (prefers-contrast) and (prefers-color-scheme: dark) { + .hasWallpaperDark .weatherInfoLink, .hasWallpaperDark .weatherButtonContextMenuWrapper { + background-color: var(--background-color-box); + } +} +@media (prefers-color-scheme: light) { + .hasWallpaperLight .weatherInfoLink, .hasWallpaperLight .weatherButtonContextMenuWrapper { + background-color: rgba(255, 255, 255, 0.7); + } + .hasWallpaperLight .weatherInfoLink:hover, .hasWallpaperLight .weatherButtonContextMenuWrapper:hover { + background-color: var(--newtab-button-static-hover-background); + } + .hasWallpaperLight .weatherInfoLink:hover:active, .hasWallpaperLight .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--newtab-button-static-active-background); + } +} +@media (prefers-contrast) and (prefers-color-scheme: light) { + .hasWallpaperLight .weatherInfoLink, .hasWallpaperLight .weatherButtonContextMenuWrapper { + background-color: var(--background-color-box); + } +} + +.weatherInfoLink { + display: flex; + gap: var(--space-medium); + padding: var(--space-small) var(--space-medium); + border-radius: var(--border-radius-medium) 0 0 var(--border-radius-medium); + text-decoration: none; + color: var(--text-color); + min-width: 130px; + max-width: 190px; + text-overflow: ellipsis; +} +@media (min-width: 610px) { + .weatherInfoLink { + min-width: unset; + } +} +.weatherInfoLink:hover ~ .weatherButtonContextMenuWrapper::after { + background-color: transparent; +} +.weatherInfoLink:focus-visible { + border-radius: var(--border-radius-medium); +} +.weatherInfoLink:focus-visible ~ .weatherButtonContextMenuWrapper::after { + background-color: transparent; +} + +.weatherButtonContextMenuWrapper { + position: relative; + cursor: pointer; + border-radius: 0 var(--border-radius-medium) var(--border-radius-medium) 0; + display: flex; + align-items: stretch; + width: 50px; + padding: 0; +} +.weatherButtonContextMenuWrapper::after { + content: ""; + left: 0; + top: 10px; + height: calc(100% - 20px); + width: 1px; + background-color: var(--newtab-button-static-background); + display: block; + position: absolute; + z-index: 0; +} +@media (prefers-color-scheme: dark) { + .weatherButtonContextMenuWrapper::after { + background-color: var(--color-gray-70); + } +} +.weatherButtonContextMenuWrapper:hover::after { + background-color: transparent; +} +.weatherButtonContextMenuWrapper:focus-visible { + border-radius: var(--border-radius-medium); +} +.weatherButtonContextMenuWrapper:focus-visible::after { + background-color: transparent; +} + +.weatherButtonContextMenu { + background-image: url("chrome://global/skin/icons/more.svg"); + background-repeat: no-repeat; + background-size: var(--size-item-small) auto; + background-position: center; + background-color: transparent; + cursor: pointer; + fill: var(--icon-color); + -moz-context-properties: fill; + width: 100%; + height: 100%; + border: 0; + appearance: none; + min-width: var(--size-item-large); +} + +.weatherText { + height: min-content; +} + +.weatherCityRow, .weatherForecastRow, .weatherDetailedSummaryRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-small); +} + +.weatherForecastRow { + text-transform: uppercase; + font-weight: var(--font-weight-bold); +} + +.weatherCityRow { + color: var(--text-color-deemphasized); +} + +.weatherCity { + text-overflow: ellipsis; + font-size: var(--font-size-small); +} + +.weatherCityRow + .weatherDetailedSummaryRow { + margin-block-start: var(--space-xsmall); +} + +.weatherDetailedSummaryRow { + font-size: var(--newtab-weather-content-font-size); + gap: var(--space-large); +} + +.weatherHighLowTemps { + display: flex; + gap: var(--space-xxsmall); + text-transform: uppercase; + word-spacing: var(--space-xxsmall); +} + +.weatherTextSummary { + text-align: center; + max-width: 90px; +} + +.weatherTemperature { + font-size: var(--font-size-large); +} + +.weatherIconCol { + width: var(--size-item-large); + height: var(--size-item-large); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + align-self: center; +} + +.weatherIcon { + width: var(--size-item-large); + height: auto; + vertical-align: middle; +} +@media (prefers-contrast) { + .weatherIcon { + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + } +} +.weatherIcon.iconId1 { + content: url("chrome://browser/skin/weather/sunny.svg"); +} +.weatherIcon.iconId2 { + content: url("chrome://browser/skin/weather/mostly-sunny.svg"); +} +.weatherIcon:is(.iconId3, .iconId4, .iconId6) { + content: url("chrome://browser/skin/weather/partly-sunny.svg"); +} +.weatherIcon.iconId5 { + content: url("chrome://browser/skin/weather/hazy-sunshine.svg"); +} +.weatherIcon:is(.iconId7, .iconId8) { + content: url("chrome://browser/skin/weather/cloudy.svg"); +} +.weatherIcon.iconId11 { + content: url("chrome://browser/skin/weather/fog.svg"); +} +.weatherIcon.iconId12 { + content: url("chrome://browser/skin/weather/showers.svg"); +} +.weatherIcon:is(.iconId13, .iconId14) { + content: url("chrome://browser/skin/weather/mostly-cloudy-with-showers.svg"); +} +.weatherIcon.iconId15 { + content: url("chrome://browser/skin/weather/thunderstorms.svg"); +} +.weatherIcon:is(.iconId16, .iconId17) { + content: url("chrome://browser/skin/weather/mostly-cloudy-with-thunderstorms.svg"); +} +.weatherIcon.iconId18 { + content: url("chrome://browser/skin/weather/rain.svg"); +} +.weatherIcon:is(.iconId19, .iconId20, .iconId25) { + content: url("chrome://browser/skin/weather/flurries.svg"); +} +.weatherIcon.iconId21 { + content: url("chrome://browser/skin/weather/partly-sunny-with-flurries.svg"); +} +.weatherIcon:is(.iconId22, .iconId23) { + content: url("chrome://browser/skin/weather/snow.svg"); +} +.weatherIcon:is(.iconId24, .iconId31) { + content: url("chrome://browser/skin/weather/ice.svg"); +} +.weatherIcon:is(.iconId26, .iconId29) { + content: url("chrome://browser/skin/weather/freezing-rain.svg"); +} +.weatherIcon.iconId30 { + content: url("chrome://browser/skin/weather/hot.svg"); +} +.weatherIcon.iconId32 { + content: url("chrome://browser/skin/weather/windy.svg"); +} +.weatherIcon.iconId33 { + content: url("chrome://browser/skin/weather/night-clear.svg"); +} +.weatherIcon:is(.iconId34, .iconId35, .iconId36, .iconId38) { + content: url("chrome://browser/skin/weather/night-mostly-clear.svg"); +} +.weatherIcon.iconId37 { + content: url("chrome://browser/skin/weather/night-hazy-moonlight.svg"); +} +.weatherIcon:is(.iconId39, .iconId40) { + content: url("chrome://browser/skin/weather/night-partly-cloudy-with-showers.svg"); + height: var(--size-item-large); +} +.weatherIcon:is(.iconId41, .iconId42) { + content: url("chrome://browser/skin/weather/night-partly-cloudy-with-thunderstorms.svg"); +} +.weatherIcon:is(.iconId43, .iconId44) { + content: url("chrome://browser/skin/weather/night-mostly-cloudy-with-flurries.svg"); +} + /* stylelint-disable max-nesting-depth */ .card-outer { background: var(--newtab-background-color-secondary); @@ -1990,14 +2354,17 @@ main section { } .card-outer .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url("chrome://global/skin/icons/more.svg"); background-position: 55%; - border: 1px solid var(--newtab-border-color); + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; border-radius: 100%; box-shadow: 0 2px rgba(12, 12, 13, 0.1); cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: 27px; inset-inline-end: -13.5px; opacity: 0; @@ -2008,10 +2375,21 @@ main section { transition-property: transform, opacity; width: 27px; } -.card-outer .context-menu-button:is(:active, :focus) { +.card-outer .context-menu-button:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } +.card-outer .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.card-outer .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.card-outer .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} .card-outer:is(:focus):not(.placeholder) { border: 0; outline: 0; @@ -2618,6 +2996,15 @@ main section { width: auto; flex-grow: 1; } +.discoverystream-admin .weather-section { + margin-block-end: 24px; +} +.discoverystream-admin .weather-section form { + display: flex; +} +.discoverystream-admin .weather-section form label { + margin-inline-end: 12px; +} .pocket-logged-in-cta { font-size: 13px; @@ -3305,6 +3692,18 @@ main section { .ds-highlights .section .section-list .card-outer a { text-decoration: none; } +.ds-highlights .section .section-list .card-outer .context-menu-button { + background-color: var(--newtab-button-static-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:hover { + background-color: var(--newtab-button-static-hover-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:focus-visible { + background-color: var(--newtab-button-static-focus-background); +} .ds-highlights .hide-for-narrow { display: block; } @@ -3566,14 +3965,17 @@ main section { .ds-card .context-menu-button, .ds-signup .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url("chrome://global/skin/icons/more.svg"); background-position: 55%; - border: 1px solid var(--newtab-border-color); + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; border-radius: 100%; box-shadow: 0 2px rgba(12, 12, 13, 0.1); cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: 27px; inset-inline-end: -13.5px; opacity: 0; @@ -3584,11 +3986,25 @@ main section { transition-property: transform, opacity; width: 27px; } -.ds-card .context-menu-button:is(:active, :focus), -.ds-signup .context-menu-button:is(:active, :focus) { +.ds-card .context-menu-button:is(:active, :focus-visible, :hover), +.ds-signup .context-menu-button:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } +.ds-card .context-menu-button:is(:hover), +.ds-signup .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.ds-card .context-menu-button:is(:focus-visible), +.ds-signup .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.ds-card .context-menu-button:is(:active), +.ds-signup .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} .ds-card .context-menu, .ds-signup .context-menu { opacity: 0; @@ -3663,11 +4079,6 @@ main section { background-size: 15px; fill: #FFF; } -.ds-card .card-stp-button-hover-background .context-menu-button { - position: static; - transition: none; - border-radius: 3px; -} .ds-card .card-stp-button-hover-background .context-menu-position-container { position: relative; } @@ -3690,6 +4101,9 @@ main section { white-space: nowrap; color: #FFF; } +.ds-card .card-stp-button-hover-background .card-stp-button:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); +} .ds-card .card-stp-button-hover-background button, .ds-card .card-stp-button-hover-background .context-menu { pointer-events: auto; @@ -3697,6 +4111,22 @@ main section { .ds-card .card-stp-button-hover-background button { cursor: pointer; } +.ds-card .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + background-color: var(--newtab-button-static-background); +} +.ds-card .context-menu-button:hover { + background-color: var(--newtab-button-static-hover-background); +} +.ds-card .context-menu-button:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.ds-card .context-menu-button:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); + background-color: var(--newtab-button-static-focus-background); +} .ds-card.last-item .card-stp-button-hover-background .context-menu { margin-inline-start: auto; margin-inline-end: 18.5px; diff --git a/browser/components/newtab/css/activity-stream-mac.css b/browser/components/newtab/css/activity-stream-mac.css index 416209d511..7b5ef57cb5 100644 --- a/browser/components/newtab/css/activity-stream-mac.css +++ b/browser/components/newtab/css/activity-stream-mac.css @@ -46,6 +46,16 @@ input { --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #000); --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #000); + --newtab-button-background: var(--button-background-color); + --newtab-button-focus-background: var(--newtab-button-background); + --newtab-button-focus-border: var(--focus-outline-color); + --newtab-button-hover-background: var(--button-background-color-hover); + --newtab-button-active-background: var(--button-background-color-active); + --newtab-button-text: var(--button-text-color); + --newtab-button-static-background: #F0F0F4; + --newtab-button-static-focus-background: var(--newtab-button-static-background); + --newtab-button-static-hover-background: #E0E0E6; + --newtab-button-static-active-background: #CFCFD8; --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); @@ -83,6 +93,9 @@ input { --newtab-primary-element-text-color: #2b2a33; --newtab-wordmark-color: #fbfbfe; --newtab-status-success: #7C6; + --newtab-button-static-background: #2B2A33; + --newtab-button-static-hover-background: #52525E; + --newtab-button-static-active-background: #5B5B66; } @media (prefers-contrast) { @@ -96,7 +109,7 @@ input { background-size: 16px; -moz-context-properties: fill; display: inline-block; - color: var(--newtab-text-primary-color); + color: var(--icon-color); fill: currentColor; height: 16px; vertical-align: middle; @@ -146,6 +159,9 @@ input { .icon.icon-info { background-image: url("chrome://global/skin/icons/info.svg"); } +.icon.icon-info-critical { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-info-critical-16.svg"); +} .icon.icon-help { background-image: url("chrome://global/skin/icons/help.svg"); } @@ -226,6 +242,9 @@ input { .icon.icon-webextension { background-image: url("chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"); } +.icon.icon-weather { + background-image: url("chrome://browser/skin/weather/sunny.svg"); +} .icon.icon-highlights { background-image: url("chrome://global/skin/icons/highlights.svg"); } @@ -663,12 +682,17 @@ main section { .top-site-outer:is(:hover) .context-menu-button { opacity: 1; } +.top-site-outer.active .context-menu-button { + opacity: 1; + background-color: var(--newtab-button-active-background); +} .top-site-outer .context-menu-button { background-image: url("chrome://global/skin/icons/more.svg"); + background-color: var(--newtab-button-background); border: 0; border-radius: 4px; cursor: pointer; - fill: var(--newtab-text-primary-color); + fill: var(--newtab-button-text); -moz-context-properties: fill; height: 20px; width: 20px; @@ -678,11 +702,16 @@ main section { top: -20px; transition: opacity 200ms; } -.top-site-outer .context-menu-button:is(:active, :focus) { - outline: 0; +.top-site-outer .context-menu-button:hover { + background-color: var(--newtab-button-hover-background); +} +.top-site-outer .context-menu-button:hover:active { + background-color: var(--newtab-button-active-background); +} +.top-site-outer .context-menu-button:focus-visible { + background-color: var(--newtab-button-focus-background); + border-color: var(--newtab-button-focus-border); opacity: 1; - background-color: var(--newtab-element-hover-color); - fill: var(--newtab-primary-action-background); } .top-site-outer .tile { border-radius: 8px; @@ -1679,7 +1708,8 @@ main section { inset-block: 0; inset-inline-end: 0; z-index: 1001; - padding: 16px; + padding-block: 0 var(--space-large); + padding-inline: var(--space-large); overflow: auto; transform: translateX(435px); visibility: hidden; @@ -1706,9 +1736,16 @@ main section { .customize-menu.customize-animate-exit-active { box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.2); } +.customize-menu .close-button-wrapper { + position: sticky; + top: 0; + padding-block-start: var(--space-large); + background-color: var(--newtab-background-color-secondary); + z-index: 1; +} .customize-menu .close-button { margin-inline-start: auto; - margin-bottom: 28px; + margin-inline-end: var(--space-large); white-space: nowrap; display: block; background-color: var(--newtab-element-secondary-color); @@ -1735,7 +1772,7 @@ main section { grid-template-columns: 1fr; grid-template-rows: repeat(4, auto); grid-row-gap: 32px; - padding: 0 16px; + padding: var(--space-large); } .home-section .wallpapers-section h2 { font-size: inherit; @@ -1982,6 +2019,333 @@ main section { text-decoration: none; } +:root { + --newtab-weather-content-font-size: 11px; + --newtab-weather-sponsor-font-size: 8px; +} + +.weather { + font-size: var(--font-size-root); + position: absolute; + left: var(--space-xlarge); + top: var(--space-xlarge); + z-index: 1; +} + +.weatherNotAvailable { + font-size: var(--newtab-weather-content-font-size); + color: var(--text-color-error); + display: flex; + align-items: center; +} +.weatherNotAvailable .icon { + fill: var(--icon-color-critical); + -moz-context-properties: fill; +} + +.weatherCard { + margin-block-end: var(--space-xsmall); + display: flex; + flex-wrap: nowrap; + align-items: stretch; + border-radius: var(--border-radius-medium); + overflow: hidden; +} +.weatherCard:hover ~ .weatherSponsorText, .weatherCard:focus-within ~ .weatherSponsorText { + visibility: visible; +} +.weatherCard:focus-within { + overflow: visible; +} +.weatherCard:hover { + box-shadow: var(--box-shadow-10); + background: var(--background-color-box); +} +.weatherCard a { + color: var(--text-color); +} + +.weatherSponsorText { + visibility: hidden; + font-size: var(--newtab-weather-sponsor-font-size); + color: var(--text-color-deemphasized); +} + +.weatherInfoLink, .weatherButtonContextMenuWrapper { + appearance: none; + background-color: var(--background-color-ghost); + border: 0; + padding: var(--space-small); + cursor: pointer; +} +.weatherInfoLink:hover, .weatherButtonContextMenuWrapper:hover { + background-color: var(--button-background-color-ghost-hover); +} +.weatherInfoLink:hover::after, .weatherButtonContextMenuWrapper:hover::after { + background-color: transparent; +} +.weatherInfoLink:hover:active, .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--button-background-color-ghost-active); +} +.weatherInfoLink:focus-visible, .weatherButtonContextMenuWrapper:focus-visible { + outline: var(--focus-outline); +} +@media (prefers-color-scheme: dark) { + .hasWallpaperDark .weatherInfoLink, .hasWallpaperDark .weatherButtonContextMenuWrapper { + background-color: rgba(35, 34, 43, 0.7); + } + .hasWallpaperDark .weatherInfoLink:hover, .hasWallpaperDark .weatherButtonContextMenuWrapper:hover { + background-color: var(--newtab-button-static-hover-background); + } + .hasWallpaperDark .weatherInfoLink:hover:active, .hasWallpaperDark .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--newtab-button-static-active-background); + } +} +@media (prefers-contrast) and (prefers-color-scheme: dark) { + .hasWallpaperDark .weatherInfoLink, .hasWallpaperDark .weatherButtonContextMenuWrapper { + background-color: var(--background-color-box); + } +} +@media (prefers-color-scheme: light) { + .hasWallpaperLight .weatherInfoLink, .hasWallpaperLight .weatherButtonContextMenuWrapper { + background-color: rgba(255, 255, 255, 0.7); + } + .hasWallpaperLight .weatherInfoLink:hover, .hasWallpaperLight .weatherButtonContextMenuWrapper:hover { + background-color: var(--newtab-button-static-hover-background); + } + .hasWallpaperLight .weatherInfoLink:hover:active, .hasWallpaperLight .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--newtab-button-static-active-background); + } +} +@media (prefers-contrast) and (prefers-color-scheme: light) { + .hasWallpaperLight .weatherInfoLink, .hasWallpaperLight .weatherButtonContextMenuWrapper { + background-color: var(--background-color-box); + } +} + +.weatherInfoLink { + display: flex; + gap: var(--space-medium); + padding: var(--space-small) var(--space-medium); + border-radius: var(--border-radius-medium) 0 0 var(--border-radius-medium); + text-decoration: none; + color: var(--text-color); + min-width: 130px; + max-width: 190px; + text-overflow: ellipsis; +} +@media (min-width: 610px) { + .weatherInfoLink { + min-width: unset; + } +} +.weatherInfoLink:hover ~ .weatherButtonContextMenuWrapper::after { + background-color: transparent; +} +.weatherInfoLink:focus-visible { + border-radius: var(--border-radius-medium); +} +.weatherInfoLink:focus-visible ~ .weatherButtonContextMenuWrapper::after { + background-color: transparent; +} + +.weatherButtonContextMenuWrapper { + position: relative; + cursor: pointer; + border-radius: 0 var(--border-radius-medium) var(--border-radius-medium) 0; + display: flex; + align-items: stretch; + width: 50px; + padding: 0; +} +.weatherButtonContextMenuWrapper::after { + content: ""; + left: 0; + top: 10px; + height: calc(100% - 20px); + width: 1px; + background-color: var(--newtab-button-static-background); + display: block; + position: absolute; + z-index: 0; +} +@media (prefers-color-scheme: dark) { + .weatherButtonContextMenuWrapper::after { + background-color: var(--color-gray-70); + } +} +.weatherButtonContextMenuWrapper:hover::after { + background-color: transparent; +} +.weatherButtonContextMenuWrapper:focus-visible { + border-radius: var(--border-radius-medium); +} +.weatherButtonContextMenuWrapper:focus-visible::after { + background-color: transparent; +} + +.weatherButtonContextMenu { + background-image: url("chrome://global/skin/icons/more.svg"); + background-repeat: no-repeat; + background-size: var(--size-item-small) auto; + background-position: center; + background-color: transparent; + cursor: pointer; + fill: var(--icon-color); + -moz-context-properties: fill; + width: 100%; + height: 100%; + border: 0; + appearance: none; + min-width: var(--size-item-large); +} + +.weatherText { + height: min-content; +} + +.weatherCityRow, .weatherForecastRow, .weatherDetailedSummaryRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-small); +} + +.weatherForecastRow { + text-transform: uppercase; + font-weight: var(--font-weight-bold); +} + +.weatherCityRow { + color: var(--text-color-deemphasized); +} + +.weatherCity { + text-overflow: ellipsis; + font-size: var(--font-size-small); +} + +.weatherCityRow + .weatherDetailedSummaryRow { + margin-block-start: var(--space-xsmall); +} + +.weatherDetailedSummaryRow { + font-size: var(--newtab-weather-content-font-size); + gap: var(--space-large); +} + +.weatherHighLowTemps { + display: flex; + gap: var(--space-xxsmall); + text-transform: uppercase; + word-spacing: var(--space-xxsmall); +} + +.weatherTextSummary { + text-align: center; + max-width: 90px; +} + +.weatherTemperature { + font-size: var(--font-size-large); +} + +.weatherIconCol { + width: var(--size-item-large); + height: var(--size-item-large); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + align-self: center; +} + +.weatherIcon { + width: var(--size-item-large); + height: auto; + vertical-align: middle; +} +@media (prefers-contrast) { + .weatherIcon { + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + } +} +.weatherIcon.iconId1 { + content: url("chrome://browser/skin/weather/sunny.svg"); +} +.weatherIcon.iconId2 { + content: url("chrome://browser/skin/weather/mostly-sunny.svg"); +} +.weatherIcon:is(.iconId3, .iconId4, .iconId6) { + content: url("chrome://browser/skin/weather/partly-sunny.svg"); +} +.weatherIcon.iconId5 { + content: url("chrome://browser/skin/weather/hazy-sunshine.svg"); +} +.weatherIcon:is(.iconId7, .iconId8) { + content: url("chrome://browser/skin/weather/cloudy.svg"); +} +.weatherIcon.iconId11 { + content: url("chrome://browser/skin/weather/fog.svg"); +} +.weatherIcon.iconId12 { + content: url("chrome://browser/skin/weather/showers.svg"); +} +.weatherIcon:is(.iconId13, .iconId14) { + content: url("chrome://browser/skin/weather/mostly-cloudy-with-showers.svg"); +} +.weatherIcon.iconId15 { + content: url("chrome://browser/skin/weather/thunderstorms.svg"); +} +.weatherIcon:is(.iconId16, .iconId17) { + content: url("chrome://browser/skin/weather/mostly-cloudy-with-thunderstorms.svg"); +} +.weatherIcon.iconId18 { + content: url("chrome://browser/skin/weather/rain.svg"); +} +.weatherIcon:is(.iconId19, .iconId20, .iconId25) { + content: url("chrome://browser/skin/weather/flurries.svg"); +} +.weatherIcon.iconId21 { + content: url("chrome://browser/skin/weather/partly-sunny-with-flurries.svg"); +} +.weatherIcon:is(.iconId22, .iconId23) { + content: url("chrome://browser/skin/weather/snow.svg"); +} +.weatherIcon:is(.iconId24, .iconId31) { + content: url("chrome://browser/skin/weather/ice.svg"); +} +.weatherIcon:is(.iconId26, .iconId29) { + content: url("chrome://browser/skin/weather/freezing-rain.svg"); +} +.weatherIcon.iconId30 { + content: url("chrome://browser/skin/weather/hot.svg"); +} +.weatherIcon.iconId32 { + content: url("chrome://browser/skin/weather/windy.svg"); +} +.weatherIcon.iconId33 { + content: url("chrome://browser/skin/weather/night-clear.svg"); +} +.weatherIcon:is(.iconId34, .iconId35, .iconId36, .iconId38) { + content: url("chrome://browser/skin/weather/night-mostly-clear.svg"); +} +.weatherIcon.iconId37 { + content: url("chrome://browser/skin/weather/night-hazy-moonlight.svg"); +} +.weatherIcon:is(.iconId39, .iconId40) { + content: url("chrome://browser/skin/weather/night-partly-cloudy-with-showers.svg"); + height: var(--size-item-large); +} +.weatherIcon:is(.iconId41, .iconId42) { + content: url("chrome://browser/skin/weather/night-partly-cloudy-with-thunderstorms.svg"); +} +.weatherIcon:is(.iconId43, .iconId44) { + content: url("chrome://browser/skin/weather/night-mostly-cloudy-with-flurries.svg"); +} + /* stylelint-disable max-nesting-depth */ .card-outer { background: var(--newtab-background-color-secondary); @@ -1994,14 +2358,17 @@ main section { } .card-outer .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url("chrome://global/skin/icons/more.svg"); background-position: 55%; - border: 1px solid var(--newtab-border-color); + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; border-radius: 100%; box-shadow: 0 2px rgba(12, 12, 13, 0.1); cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: 27px; inset-inline-end: -13.5px; opacity: 0; @@ -2012,10 +2379,21 @@ main section { transition-property: transform, opacity; width: 27px; } -.card-outer .context-menu-button:is(:active, :focus) { +.card-outer .context-menu-button:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } +.card-outer .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.card-outer .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.card-outer .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} .card-outer:is(:focus):not(.placeholder) { border: 0; outline: 0; @@ -2622,6 +3000,15 @@ main section { width: auto; flex-grow: 1; } +.discoverystream-admin .weather-section { + margin-block-end: 24px; +} +.discoverystream-admin .weather-section form { + display: flex; +} +.discoverystream-admin .weather-section form label { + margin-inline-end: 12px; +} .pocket-logged-in-cta { font-size: 13px; @@ -3309,6 +3696,18 @@ main section { .ds-highlights .section .section-list .card-outer a { text-decoration: none; } +.ds-highlights .section .section-list .card-outer .context-menu-button { + background-color: var(--newtab-button-static-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:hover { + background-color: var(--newtab-button-static-hover-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:focus-visible { + background-color: var(--newtab-button-static-focus-background); +} .ds-highlights .hide-for-narrow { display: block; } @@ -3570,14 +3969,17 @@ main section { .ds-card .context-menu-button, .ds-signup .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url("chrome://global/skin/icons/more.svg"); background-position: 55%; - border: 1px solid var(--newtab-border-color); + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; border-radius: 100%; box-shadow: 0 2px rgba(12, 12, 13, 0.1); cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: 27px; inset-inline-end: -13.5px; opacity: 0; @@ -3588,11 +3990,25 @@ main section { transition-property: transform, opacity; width: 27px; } -.ds-card .context-menu-button:is(:active, :focus), -.ds-signup .context-menu-button:is(:active, :focus) { +.ds-card .context-menu-button:is(:active, :focus-visible, :hover), +.ds-signup .context-menu-button:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } +.ds-card .context-menu-button:is(:hover), +.ds-signup .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.ds-card .context-menu-button:is(:focus-visible), +.ds-signup .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.ds-card .context-menu-button:is(:active), +.ds-signup .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} .ds-card .context-menu, .ds-signup .context-menu { opacity: 0; @@ -3667,11 +4083,6 @@ main section { background-size: 15px; fill: #FFF; } -.ds-card .card-stp-button-hover-background .context-menu-button { - position: static; - transition: none; - border-radius: 3px; -} .ds-card .card-stp-button-hover-background .context-menu-position-container { position: relative; } @@ -3694,6 +4105,9 @@ main section { white-space: nowrap; color: #FFF; } +.ds-card .card-stp-button-hover-background .card-stp-button:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); +} .ds-card .card-stp-button-hover-background button, .ds-card .card-stp-button-hover-background .context-menu { pointer-events: auto; @@ -3701,6 +4115,22 @@ main section { .ds-card .card-stp-button-hover-background button { cursor: pointer; } +.ds-card .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + background-color: var(--newtab-button-static-background); +} +.ds-card .context-menu-button:hover { + background-color: var(--newtab-button-static-hover-background); +} +.ds-card .context-menu-button:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.ds-card .context-menu-button:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); + background-color: var(--newtab-button-static-focus-background); +} .ds-card.last-item .card-stp-button-hover-background .context-menu { margin-inline-start: auto; margin-inline-end: 18.5px; diff --git a/browser/components/newtab/css/activity-stream-windows.css b/browser/components/newtab/css/activity-stream-windows.css index f6118e3c18..96b27e6b5f 100644 --- a/browser/components/newtab/css/activity-stream-windows.css +++ b/browser/components/newtab/css/activity-stream-windows.css @@ -42,6 +42,16 @@ input { --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #000); --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #000); + --newtab-button-background: var(--button-background-color); + --newtab-button-focus-background: var(--newtab-button-background); + --newtab-button-focus-border: var(--focus-outline-color); + --newtab-button-hover-background: var(--button-background-color-hover); + --newtab-button-active-background: var(--button-background-color-active); + --newtab-button-text: var(--button-text-color); + --newtab-button-static-background: #F0F0F4; + --newtab-button-static-focus-background: var(--newtab-button-static-background); + --newtab-button-static-hover-background: #E0E0E6; + --newtab-button-static-active-background: #CFCFD8; --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); @@ -79,6 +89,9 @@ input { --newtab-primary-element-text-color: #2b2a33; --newtab-wordmark-color: #fbfbfe; --newtab-status-success: #7C6; + --newtab-button-static-background: #2B2A33; + --newtab-button-static-hover-background: #52525E; + --newtab-button-static-active-background: #5B5B66; } @media (prefers-contrast) { @@ -92,7 +105,7 @@ input { background-size: 16px; -moz-context-properties: fill; display: inline-block; - color: var(--newtab-text-primary-color); + color: var(--icon-color); fill: currentColor; height: 16px; vertical-align: middle; @@ -142,6 +155,9 @@ input { .icon.icon-info { background-image: url("chrome://global/skin/icons/info.svg"); } +.icon.icon-info-critical { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-info-critical-16.svg"); +} .icon.icon-help { background-image: url("chrome://global/skin/icons/help.svg"); } @@ -222,6 +238,9 @@ input { .icon.icon-webextension { background-image: url("chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"); } +.icon.icon-weather { + background-image: url("chrome://browser/skin/weather/sunny.svg"); +} .icon.icon-highlights { background-image: url("chrome://global/skin/icons/highlights.svg"); } @@ -659,12 +678,17 @@ main section { .top-site-outer:is(:hover) .context-menu-button { opacity: 1; } +.top-site-outer.active .context-menu-button { + opacity: 1; + background-color: var(--newtab-button-active-background); +} .top-site-outer .context-menu-button { background-image: url("chrome://global/skin/icons/more.svg"); + background-color: var(--newtab-button-background); border: 0; border-radius: 4px; cursor: pointer; - fill: var(--newtab-text-primary-color); + fill: var(--newtab-button-text); -moz-context-properties: fill; height: 20px; width: 20px; @@ -674,11 +698,16 @@ main section { top: -20px; transition: opacity 200ms; } -.top-site-outer .context-menu-button:is(:active, :focus) { - outline: 0; +.top-site-outer .context-menu-button:hover { + background-color: var(--newtab-button-hover-background); +} +.top-site-outer .context-menu-button:hover:active { + background-color: var(--newtab-button-active-background); +} +.top-site-outer .context-menu-button:focus-visible { + background-color: var(--newtab-button-focus-background); + border-color: var(--newtab-button-focus-border); opacity: 1; - background-color: var(--newtab-element-hover-color); - fill: var(--newtab-primary-action-background); } .top-site-outer .tile { border-radius: 8px; @@ -1675,7 +1704,8 @@ main section { inset-block: 0; inset-inline-end: 0; z-index: 1001; - padding: 16px; + padding-block: 0 var(--space-large); + padding-inline: var(--space-large); overflow: auto; transform: translateX(435px); visibility: hidden; @@ -1702,9 +1732,16 @@ main section { .customize-menu.customize-animate-exit-active { box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.2); } +.customize-menu .close-button-wrapper { + position: sticky; + top: 0; + padding-block-start: var(--space-large); + background-color: var(--newtab-background-color-secondary); + z-index: 1; +} .customize-menu .close-button { margin-inline-start: auto; - margin-bottom: 28px; + margin-inline-end: var(--space-large); white-space: nowrap; display: block; background-color: var(--newtab-element-secondary-color); @@ -1731,7 +1768,7 @@ main section { grid-template-columns: 1fr; grid-template-rows: repeat(4, auto); grid-row-gap: 32px; - padding: 0 16px; + padding: var(--space-large); } .home-section .wallpapers-section h2 { font-size: inherit; @@ -1978,6 +2015,333 @@ main section { text-decoration: none; } +:root { + --newtab-weather-content-font-size: 11px; + --newtab-weather-sponsor-font-size: 8px; +} + +.weather { + font-size: var(--font-size-root); + position: absolute; + left: var(--space-xlarge); + top: var(--space-xlarge); + z-index: 1; +} + +.weatherNotAvailable { + font-size: var(--newtab-weather-content-font-size); + color: var(--text-color-error); + display: flex; + align-items: center; +} +.weatherNotAvailable .icon { + fill: var(--icon-color-critical); + -moz-context-properties: fill; +} + +.weatherCard { + margin-block-end: var(--space-xsmall); + display: flex; + flex-wrap: nowrap; + align-items: stretch; + border-radius: var(--border-radius-medium); + overflow: hidden; +} +.weatherCard:hover ~ .weatherSponsorText, .weatherCard:focus-within ~ .weatherSponsorText { + visibility: visible; +} +.weatherCard:focus-within { + overflow: visible; +} +.weatherCard:hover { + box-shadow: var(--box-shadow-10); + background: var(--background-color-box); +} +.weatherCard a { + color: var(--text-color); +} + +.weatherSponsorText { + visibility: hidden; + font-size: var(--newtab-weather-sponsor-font-size); + color: var(--text-color-deemphasized); +} + +.weatherInfoLink, .weatherButtonContextMenuWrapper { + appearance: none; + background-color: var(--background-color-ghost); + border: 0; + padding: var(--space-small); + cursor: pointer; +} +.weatherInfoLink:hover, .weatherButtonContextMenuWrapper:hover { + background-color: var(--button-background-color-ghost-hover); +} +.weatherInfoLink:hover::after, .weatherButtonContextMenuWrapper:hover::after { + background-color: transparent; +} +.weatherInfoLink:hover:active, .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--button-background-color-ghost-active); +} +.weatherInfoLink:focus-visible, .weatherButtonContextMenuWrapper:focus-visible { + outline: var(--focus-outline); +} +@media (prefers-color-scheme: dark) { + .hasWallpaperDark .weatherInfoLink, .hasWallpaperDark .weatherButtonContextMenuWrapper { + background-color: rgba(35, 34, 43, 0.7); + } + .hasWallpaperDark .weatherInfoLink:hover, .hasWallpaperDark .weatherButtonContextMenuWrapper:hover { + background-color: var(--newtab-button-static-hover-background); + } + .hasWallpaperDark .weatherInfoLink:hover:active, .hasWallpaperDark .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--newtab-button-static-active-background); + } +} +@media (prefers-contrast) and (prefers-color-scheme: dark) { + .hasWallpaperDark .weatherInfoLink, .hasWallpaperDark .weatherButtonContextMenuWrapper { + background-color: var(--background-color-box); + } +} +@media (prefers-color-scheme: light) { + .hasWallpaperLight .weatherInfoLink, .hasWallpaperLight .weatherButtonContextMenuWrapper { + background-color: rgba(255, 255, 255, 0.7); + } + .hasWallpaperLight .weatherInfoLink:hover, .hasWallpaperLight .weatherButtonContextMenuWrapper:hover { + background-color: var(--newtab-button-static-hover-background); + } + .hasWallpaperLight .weatherInfoLink:hover:active, .hasWallpaperLight .weatherButtonContextMenuWrapper:hover:active { + background-color: var(--newtab-button-static-active-background); + } +} +@media (prefers-contrast) and (prefers-color-scheme: light) { + .hasWallpaperLight .weatherInfoLink, .hasWallpaperLight .weatherButtonContextMenuWrapper { + background-color: var(--background-color-box); + } +} + +.weatherInfoLink { + display: flex; + gap: var(--space-medium); + padding: var(--space-small) var(--space-medium); + border-radius: var(--border-radius-medium) 0 0 var(--border-radius-medium); + text-decoration: none; + color: var(--text-color); + min-width: 130px; + max-width: 190px; + text-overflow: ellipsis; +} +@media (min-width: 610px) { + .weatherInfoLink { + min-width: unset; + } +} +.weatherInfoLink:hover ~ .weatherButtonContextMenuWrapper::after { + background-color: transparent; +} +.weatherInfoLink:focus-visible { + border-radius: var(--border-radius-medium); +} +.weatherInfoLink:focus-visible ~ .weatherButtonContextMenuWrapper::after { + background-color: transparent; +} + +.weatherButtonContextMenuWrapper { + position: relative; + cursor: pointer; + border-radius: 0 var(--border-radius-medium) var(--border-radius-medium) 0; + display: flex; + align-items: stretch; + width: 50px; + padding: 0; +} +.weatherButtonContextMenuWrapper::after { + content: ""; + left: 0; + top: 10px; + height: calc(100% - 20px); + width: 1px; + background-color: var(--newtab-button-static-background); + display: block; + position: absolute; + z-index: 0; +} +@media (prefers-color-scheme: dark) { + .weatherButtonContextMenuWrapper::after { + background-color: var(--color-gray-70); + } +} +.weatherButtonContextMenuWrapper:hover::after { + background-color: transparent; +} +.weatherButtonContextMenuWrapper:focus-visible { + border-radius: var(--border-radius-medium); +} +.weatherButtonContextMenuWrapper:focus-visible::after { + background-color: transparent; +} + +.weatherButtonContextMenu { + background-image: url("chrome://global/skin/icons/more.svg"); + background-repeat: no-repeat; + background-size: var(--size-item-small) auto; + background-position: center; + background-color: transparent; + cursor: pointer; + fill: var(--icon-color); + -moz-context-properties: fill; + width: 100%; + height: 100%; + border: 0; + appearance: none; + min-width: var(--size-item-large); +} + +.weatherText { + height: min-content; +} + +.weatherCityRow, .weatherForecastRow, .weatherDetailedSummaryRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-small); +} + +.weatherForecastRow { + text-transform: uppercase; + font-weight: var(--font-weight-bold); +} + +.weatherCityRow { + color: var(--text-color-deemphasized); +} + +.weatherCity { + text-overflow: ellipsis; + font-size: var(--font-size-small); +} + +.weatherCityRow + .weatherDetailedSummaryRow { + margin-block-start: var(--space-xsmall); +} + +.weatherDetailedSummaryRow { + font-size: var(--newtab-weather-content-font-size); + gap: var(--space-large); +} + +.weatherHighLowTemps { + display: flex; + gap: var(--space-xxsmall); + text-transform: uppercase; + word-spacing: var(--space-xxsmall); +} + +.weatherTextSummary { + text-align: center; + max-width: 90px; +} + +.weatherTemperature { + font-size: var(--font-size-large); +} + +.weatherIconCol { + width: var(--size-item-large); + height: var(--size-item-large); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + align-self: center; +} + +.weatherIcon { + width: var(--size-item-large); + height: auto; + vertical-align: middle; +} +@media (prefers-contrast) { + .weatherIcon { + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + } +} +.weatherIcon.iconId1 { + content: url("chrome://browser/skin/weather/sunny.svg"); +} +.weatherIcon.iconId2 { + content: url("chrome://browser/skin/weather/mostly-sunny.svg"); +} +.weatherIcon:is(.iconId3, .iconId4, .iconId6) { + content: url("chrome://browser/skin/weather/partly-sunny.svg"); +} +.weatherIcon.iconId5 { + content: url("chrome://browser/skin/weather/hazy-sunshine.svg"); +} +.weatherIcon:is(.iconId7, .iconId8) { + content: url("chrome://browser/skin/weather/cloudy.svg"); +} +.weatherIcon.iconId11 { + content: url("chrome://browser/skin/weather/fog.svg"); +} +.weatherIcon.iconId12 { + content: url("chrome://browser/skin/weather/showers.svg"); +} +.weatherIcon:is(.iconId13, .iconId14) { + content: url("chrome://browser/skin/weather/mostly-cloudy-with-showers.svg"); +} +.weatherIcon.iconId15 { + content: url("chrome://browser/skin/weather/thunderstorms.svg"); +} +.weatherIcon:is(.iconId16, .iconId17) { + content: url("chrome://browser/skin/weather/mostly-cloudy-with-thunderstorms.svg"); +} +.weatherIcon.iconId18 { + content: url("chrome://browser/skin/weather/rain.svg"); +} +.weatherIcon:is(.iconId19, .iconId20, .iconId25) { + content: url("chrome://browser/skin/weather/flurries.svg"); +} +.weatherIcon.iconId21 { + content: url("chrome://browser/skin/weather/partly-sunny-with-flurries.svg"); +} +.weatherIcon:is(.iconId22, .iconId23) { + content: url("chrome://browser/skin/weather/snow.svg"); +} +.weatherIcon:is(.iconId24, .iconId31) { + content: url("chrome://browser/skin/weather/ice.svg"); +} +.weatherIcon:is(.iconId26, .iconId29) { + content: url("chrome://browser/skin/weather/freezing-rain.svg"); +} +.weatherIcon.iconId30 { + content: url("chrome://browser/skin/weather/hot.svg"); +} +.weatherIcon.iconId32 { + content: url("chrome://browser/skin/weather/windy.svg"); +} +.weatherIcon.iconId33 { + content: url("chrome://browser/skin/weather/night-clear.svg"); +} +.weatherIcon:is(.iconId34, .iconId35, .iconId36, .iconId38) { + content: url("chrome://browser/skin/weather/night-mostly-clear.svg"); +} +.weatherIcon.iconId37 { + content: url("chrome://browser/skin/weather/night-hazy-moonlight.svg"); +} +.weatherIcon:is(.iconId39, .iconId40) { + content: url("chrome://browser/skin/weather/night-partly-cloudy-with-showers.svg"); + height: var(--size-item-large); +} +.weatherIcon:is(.iconId41, .iconId42) { + content: url("chrome://browser/skin/weather/night-partly-cloudy-with-thunderstorms.svg"); +} +.weatherIcon:is(.iconId43, .iconId44) { + content: url("chrome://browser/skin/weather/night-mostly-cloudy-with-flurries.svg"); +} + /* stylelint-disable max-nesting-depth */ .card-outer { background: var(--newtab-background-color-secondary); @@ -1990,14 +2354,17 @@ main section { } .card-outer .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url("chrome://global/skin/icons/more.svg"); background-position: 55%; - border: 1px solid var(--newtab-border-color); + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; border-radius: 100%; box-shadow: 0 2px rgba(12, 12, 13, 0.1); cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: 27px; inset-inline-end: -13.5px; opacity: 0; @@ -2008,10 +2375,21 @@ main section { transition-property: transform, opacity; width: 27px; } -.card-outer .context-menu-button:is(:active, :focus) { +.card-outer .context-menu-button:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } +.card-outer .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.card-outer .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.card-outer .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} .card-outer:is(:focus):not(.placeholder) { border: 0; outline: 0; @@ -2618,6 +2996,15 @@ main section { width: auto; flex-grow: 1; } +.discoverystream-admin .weather-section { + margin-block-end: 24px; +} +.discoverystream-admin .weather-section form { + display: flex; +} +.discoverystream-admin .weather-section form label { + margin-inline-end: 12px; +} .pocket-logged-in-cta { font-size: 13px; @@ -3305,6 +3692,18 @@ main section { .ds-highlights .section .section-list .card-outer a { text-decoration: none; } +.ds-highlights .section .section-list .card-outer .context-menu-button { + background-color: var(--newtab-button-static-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:hover { + background-color: var(--newtab-button-static-hover-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.ds-highlights .section .section-list .card-outer .context-menu-button:focus-visible { + background-color: var(--newtab-button-static-focus-background); +} .ds-highlights .hide-for-narrow { display: block; } @@ -3566,14 +3965,17 @@ main section { .ds-card .context-menu-button, .ds-signup .context-menu-button { background-clip: padding-box; - background-color: var(--newtab-background-color-secondary); + background-color: var(--newtab-button-background); background-image: url("chrome://global/skin/icons/more.svg"); background-position: 55%; - border: 1px solid var(--newtab-border-color); + border: 0; + outline: 1px solid var(--newtab-border-color); + outline-width: 0; border-radius: 100%; box-shadow: 0 2px rgba(12, 12, 13, 0.1); cursor: pointer; - fill: var(--newtab-text-primary-color); + color: var(--button-text-color); + fill: var(--newtab-button-text); height: 27px; inset-inline-end: -13.5px; opacity: 0; @@ -3584,11 +3986,25 @@ main section { transition-property: transform, opacity; width: 27px; } -.ds-card .context-menu-button:is(:active, :focus), -.ds-signup .context-menu-button:is(:active, :focus) { +.ds-card .context-menu-button:is(:active, :focus-visible, :hover), +.ds-signup .context-menu-button:is(:active, :focus-visible, :hover) { opacity: 1; transform: scale(1); } +.ds-card .context-menu-button:is(:hover), +.ds-signup .context-menu-button:is(:hover) { + background-color: var(--newtab-button-hover-background); +} +.ds-card .context-menu-button:is(:focus-visible), +.ds-signup .context-menu-button:is(:focus-visible) { + outline-color: var(--newtab-button-focus-border); + background-color: var(--newtab-button-focus-background); + outline-width: 4px; +} +.ds-card .context-menu-button:is(:active), +.ds-signup .context-menu-button:is(:active) { + background-color: var(--newtab-button-active-background); +} .ds-card .context-menu, .ds-signup .context-menu { opacity: 0; @@ -3663,11 +4079,6 @@ main section { background-size: 15px; fill: #FFF; } -.ds-card .card-stp-button-hover-background .context-menu-button { - position: static; - transition: none; - border-radius: 3px; -} .ds-card .card-stp-button-hover-background .context-menu-position-container { position: relative; } @@ -3690,6 +4101,9 @@ main section { white-space: nowrap; color: #FFF; } +.ds-card .card-stp-button-hover-background .card-stp-button:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); +} .ds-card .card-stp-button-hover-background button, .ds-card .card-stp-button-hover-background .context-menu { pointer-events: auto; @@ -3697,6 +4111,22 @@ main section { .ds-card .card-stp-button-hover-background button { cursor: pointer; } +.ds-card .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + background-color: var(--newtab-button-static-background); +} +.ds-card .context-menu-button:hover { + background-color: var(--newtab-button-static-hover-background); +} +.ds-card .context-menu-button:hover:active { + background-color: var(--newtab-button-static-active-background); +} +.ds-card .context-menu-button:focus-visible { + outline: 2px solid var(--newtab-button-focus-border); + background-color: var(--newtab-button-static-focus-background); +} .ds-card.last-item .card-stp-button-hover-background .context-menu { margin-inline-start: auto; margin-inline-end: 18.5px; diff --git a/browser/components/newtab/data/content/activity-stream.bundle.js b/browser/components/newtab/data/content/activity-stream.bundle.js index 395e8c5bb3..45705fe58c 100644 --- a/browser/components/newtab/data/content/activity-stream.bundle.js +++ b/browser/components/newtab/data/content/activity-stream.bundle.js @@ -234,6 +234,11 @@ for (const type of [ "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WALLPAPERS_SET", + "WALLPAPER_CLICK", + "WEATHER_IMPRESSION", + "WEATHER_LOAD_ERROR", + "WEATHER_OPEN_PROVIDER_URL", + "WEATHER_UPDATE", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { @@ -675,8 +680,11 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { this.systemTick = this.systemTick.bind(this); this.syncRemoteSettings = this.syncRemoteSettings.bind(this); this.onStoryToggle = this.onStoryToggle.bind(this); + this.handleWeatherSubmit = this.handleWeatherSubmit.bind(this); + this.handleWeatherUpdate = this.handleWeatherUpdate.bind(this); this.state = { - toggledStories: {} + toggledStories: {}, + weatherQuery: "" }; } setConfigValue(name, value) { @@ -719,6 +727,18 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { syncRemoteSettings() { this.dispatchSimpleAction(actionTypes.DISCOVERY_STREAM_DEV_SYNC_RS); } + handleWeatherUpdate(e) { + this.setState({ + weatherQuery: e.target.value || "" + }); + } + handleWeatherSubmit(e) { + e.preventDefault(); + const { + weatherQuery + } = this.state; + this.props.dispatch(actionCreators.SetPref("weather.query", weatherQuery)); + } renderComponent(width, component) { return /*#__PURE__*/external_React_default().createElement("table", null, /*#__PURE__*/external_React_default().createElement("tbody", null, /*#__PURE__*/external_React_default().createElement(Row, null, /*#__PURE__*/external_React_default().createElement("td", { className: "min" @@ -726,6 +746,38 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { className: "min" }, "Width"), /*#__PURE__*/external_React_default().createElement("td", null, width)), component.feed && this.renderFeed(component.feed))); } + renderWeatherData() { + const { + suggestions + } = this.props.state.Weather; + let weatherTable; + if (suggestions) { + weatherTable = /*#__PURE__*/external_React_default().createElement("div", { + className: "weather-section" + }, /*#__PURE__*/external_React_default().createElement("form", { + onSubmit: this.handleWeatherSubmit + }, /*#__PURE__*/external_React_default().createElement("label", { + htmlFor: "weather-query" + }, "Weather query"), /*#__PURE__*/external_React_default().createElement("input", { + type: "text", + min: "3", + max: "10", + id: "weather-query", + onChange: this.handleWeatherUpdate, + value: this.weatherQuery + }), /*#__PURE__*/external_React_default().createElement("button", { + type: "submit" + }, "Submit")), /*#__PURE__*/external_React_default().createElement("table", null, /*#__PURE__*/external_React_default().createElement("tbody", null, suggestions.map(suggestion => /*#__PURE__*/external_React_default().createElement("tr", { + className: "message-item", + key: suggestion.city_name + }, /*#__PURE__*/external_React_default().createElement("td", { + className: "message-id" + }, /*#__PURE__*/external_React_default().createElement("span", null, suggestion.city_name, " ", /*#__PURE__*/external_React_default().createElement("br", null))), /*#__PURE__*/external_React_default().createElement("td", { + className: "message-summary" + }, /*#__PURE__*/external_React_default().createElement("pre", null, JSON.stringify(suggestion, null, 2)))))))); + } + return weatherTable; + } renderFeedData(url) { const { feeds @@ -836,7 +888,7 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { state: { Personalization: this.props.state.Personalization } - }), /*#__PURE__*/external_React_default().createElement("h3", null, "Spocs"), this.renderSpocs(), /*#__PURE__*/external_React_default().createElement("h3", null, "Feeds Data"), this.renderFeedsData()); + }), /*#__PURE__*/external_React_default().createElement("h3", null, "Spocs"), this.renderSpocs(), /*#__PURE__*/external_React_default().createElement("h3", null, "Feeds Data"), this.renderFeedsData(), /*#__PURE__*/external_React_default().createElement("h3", null, "Weather Data"), this.renderWeatherData()); } } class DiscoveryStreamAdminInner extends (external_React_default()).PureComponent { @@ -859,7 +911,8 @@ class DiscoveryStreamAdminInner extends (external_React_default()).PureComponent }, "Click here"))), /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, /*#__PURE__*/external_React_default().createElement(DiscoveryStreamAdminUI, { state: { DiscoveryStream: this.props.DiscoveryStream, - Personalization: this.props.Personalization + Personalization: this.props.Personalization, + Weather: this.props.Weather }, otherPrefs: this.props.Prefs.values, dispatch: this.props.dispatch @@ -929,7 +982,8 @@ const DiscoveryStreamAdmin = (0,external_ReactRedux_namespaceObject.connect)(sta Sections: state.Sections, DiscoveryStream: state.DiscoveryStream, Personalization: state.Personalization, - Prefs: state.Prefs + Prefs: state.Prefs, + Weather: state.Weather }))(_DiscoveryStreamAdmin); ;// CONCATENATED MODULE: ./content-src/components/ConfirmDialog/ConfirmDialog.jsx /* This Source Code Form is subject to the terms of the Mozilla Public @@ -1704,6 +1758,70 @@ const LinkMenuOptions = { : LinkMenuOptions.EmptyItem(), OpenInPrivateWindow: (site, index, eventSource, isEnabled) => isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(), + ChangeWeatherLocation: () => ({ + id: "newtab-weather-menu-change-location", + action: actionCreators.OnlyToMain({ + type: actionTypes.CHANGE_WEATHER_LOCATION, + data: { url: "https://mozilla.org" }, + }), + }), + ChangeWeatherDisplaySimple: () => ({ + id: "newtab-weather-menu-change-weather-display-simple", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "weather.display", + value: "simple", + }, + }), + }), + ChangeWeatherDisplayDetailed: () => ({ + id: "newtab-weather-menu-change-weather-display-detailed", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "weather.display", + value: "detailed", + }, + }), + }), + ChangeTempUnitFahrenheit: () => ({ + id: "newtab-weather-menu-change-temperature-units-fahrenheit", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "weather.temperatureUnits", + value: "f", + }, + }), + }), + ChangeTempUnitCelsius: () => ({ + id: "newtab-weather-menu-change-temperature-units-celsius", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "weather.temperatureUnits", + value: "c", + }, + }), + }), + HideWeather: () => ({ + id: "newtab-weather-menu-hide-weather", + action: actionCreators.OnlyToMain({ + type: actionTypes.SET_PREF, + data: { + name: "showWeather", + value: false, + }, + }), + }), + OpenLearnMoreURL: site => ({ + id: "newtab-weather-menu-learn-more", + action: actionCreators.OnlyToMain({ + type: actionTypes.OPEN_LINK, + data: { url: site.url }, + }), + }), }; ;// CONCATENATED MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx @@ -2967,11 +3085,11 @@ class _DSCard extends (external_React_default()).PureComponent { ctaButtonVariant: ctaButtonVariant, dispatch: this.props.dispatch, spocMessageVariant: this.props.spocMessageVariant - }), saveToPocketCard && /*#__PURE__*/external_React_default().createElement("div", { + }), /*#__PURE__*/external_React_default().createElement("div", { className: "card-stp-button-hover-background" }, /*#__PURE__*/external_React_default().createElement("div", { className: "card-stp-button-position-wrapper" - }, !this.props.flightId && stpButton(), /*#__PURE__*/external_React_default().createElement(DSLinkMenu, { + }, saveToPocketCard && /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, !this.props.flightId && stpButton()), /*#__PURE__*/external_React_default().createElement(DSLinkMenu, { id: this.props.id, index: this.props.pos, dispatch: this.props.dispatch, @@ -2989,25 +3107,7 @@ class _DSCard extends (external_React_default()).PureComponent { saveToPocketCard: saveToPocketCard, pocket_button_enabled: pocketButtonEnabled, isRecentSave: isRecentSave - }))), !saveToPocketCard && /*#__PURE__*/external_React_default().createElement(DSLinkMenu, { - id: this.props.id, - index: this.props.pos, - dispatch: this.props.dispatch, - url: this.props.url, - title: this.props.title, - source: source, - type: this.props.type, - pocket_id: this.props.pocket_id, - shim: this.props.shim, - bookmarkGuid: this.props.bookmarkGuid, - flightId: !this.props.is_collection ? this.props.flightId : undefined, - showPrivacyInfo: !!this.props.flightId, - hostRef: this.contextMenuButtonHostRef, - onMenuUpdate: this.onMenuUpdate, - onMenuShow: this.onMenuShow, - pocket_button_enabled: pocketButtonEnabled, - isRecentSave: isRecentSave - })); + })))); } } _DSCard.defaultProps = { @@ -5534,6 +5634,12 @@ const INITIAL_STATE = { Wallpapers: { wallpaperList: [], }, + Weather: { + // do we have the data from WeatherFeed yet? + initialized: false, + suggestions: [], + lastUpdated: null, + }, }; function App(prevState = INITIAL_STATE.App, action) { @@ -6283,6 +6389,20 @@ function Wallpapers(prevState = INITIAL_STATE.Wallpapers, action) { } } +function Weather(prevState = INITIAL_STATE.Weather, action) { + switch (action.type) { + case actionTypes.WEATHER_UPDATE: + return { + ...prevState, + suggestions: action.data.suggestions, + lastUpdated: action.data.date, + initialized: true, + }; + default: + return prevState; + } +} + const reducers = { TopSites, App, @@ -6295,6 +6415,7 @@ const reducers = { DiscoveryStream, Search, Wallpapers, + Weather, }; ;// CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteFormInput.jsx @@ -8854,6 +8975,7 @@ const DiscoveryStreamBase = (0,external_ReactRedux_namespaceObject.connect)(stat + class _WallpapersSection extends (external_React_default()).PureComponent { constructor(props) { super(props); @@ -8872,6 +8994,10 @@ class _WallpapersSection extends (external_React_default()).PureComponent { const prefs = this.props.Prefs.values; const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, id); + this.handleUserEvent({ + selected_wallpaper: id, + hadPreviousWallpaper: !!this.props.activeWallpaper + }); // bug 1892095 if (prefs["newtabWallpapers.wallpaper-dark"] === "" && colorMode === "light") { this.props.setPref("newtabWallpapers.wallpaper-dark", id.replace("light", "dark")); @@ -8883,6 +9009,18 @@ class _WallpapersSection extends (external_React_default()).PureComponent { handleReset() { const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light"; this.props.setPref(`newtabWallpapers.wallpaper-${colorMode}`, ""); + this.handleUserEvent({ + selected_wallpaper: "none", + hadPreviousWallpaper: !!this.props.activeWallpaper + }); + } + + // Record user interaction when changing wallpaper and reseting wallpaper to default + handleUserEvent(data) { + this.props.dispatch(actionCreators.OnlyToMain({ + type: actionTypes.WALLPAPER_CLICK, + data + })); } render() { const { @@ -8954,7 +9092,7 @@ class ContentSection extends (external_React_default()).PureComponent { })); } onPreferenceSelect(e) { - // eventSource: TOP_SITES | TOP_STORIES | HIGHLIGHTS + // eventSource: TOP_SITES | TOP_STORIES | HIGHLIGHTS | WEATHER const { preference, eventSource @@ -9011,6 +9149,7 @@ class ContentSection extends (external_React_default()).PureComponent { pocketRegion, mayHaveSponsoredStories, mayHaveRecentSaves, + mayHaveWeather, openPreferences, spocMessageVariant, wallpapersEnabled, @@ -9021,6 +9160,7 @@ class ContentSection extends (external_React_default()).PureComponent { topSitesEnabled, pocketEnabled, highlightsEnabled, + weatherEnabled, showSponsoredTopSitesEnabled, showSponsoredPocketEnabled, showRecentSavesEnabled, @@ -9156,6 +9296,19 @@ class ContentSection extends (external_React_default()).PureComponent { "data-eventSource": "HIGHLIGHTS", "data-l10n-id": "newtab-custom-recent-toggle", "data-l10n-attrs": "label, description" + }))), mayHaveWeather && /*#__PURE__*/external_React_default().createElement("div", { + id: "weather-section", + className: "section" + }, /*#__PURE__*/external_React_default().createElement("label", { + className: "switch" + }, /*#__PURE__*/external_React_default().createElement("moz-toggle", { + id: "weather-toggle", + pressed: weatherEnabled || null, + onToggle: this.onPreferenceSelect, + "data-preference": "showWeather", + "data-eventSource": "WEATHER", + "data-l10n-id": "newtab-custom-weather-toggle", + "data-l10n-attrs": "label, description" }))), pocketRegion && mayHaveSponsoredStories && spocMessageVariant === "variant-c" && /*#__PURE__*/external_React_default().createElement("div", { className: "sponsored-content-info" }, /*#__PURE__*/external_React_default().createElement("div", { @@ -9221,12 +9374,14 @@ class _CustomizeMenu extends (external_React_default()).PureComponent { className: "customize-menu", role: "dialog", "data-l10n-id": "newtab-personalize-dialog-label" + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "close-button-wrapper" }, /*#__PURE__*/external_React_default().createElement("button", { onClick: () => this.props.onClose(), className: "close-button", "data-l10n-id": "newtab-custom-close-button", ref: c => this.closeButton = c - }), /*#__PURE__*/external_React_default().createElement(ContentSection, { + })), /*#__PURE__*/external_React_default().createElement(ContentSection, { openPreferences: this.props.openPreferences, setPref: this.props.setPref, enabledSections: this.props.enabledSections, @@ -9236,6 +9391,7 @@ class _CustomizeMenu extends (external_React_default()).PureComponent { mayHaveSponsoredTopSites: this.props.mayHaveSponsoredTopSites, mayHaveSponsoredStories: this.props.mayHaveSponsoredStories, mayHaveRecentSaves: this.props.DiscoveryStream.recentSavesEnabled, + mayHaveWeather: this.props.mayHaveWeather, spocMessageVariant: this.props.spocMessageVariant, dispatch: this.props.dispatch })))); @@ -9449,6 +9605,260 @@ class _Search extends (external_React_default()).PureComponent { const Search_Search = (0,external_ReactRedux_namespaceObject.connect)(state => ({ Prefs: state.Prefs }))(_Search); +;// CONCATENATED MODULE: ./content-src/components/Weather/Weather.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/. */ + + + + + +const Weather_VISIBLE = "visible"; +const Weather_VISIBILITY_CHANGE_EVENT = "visibilitychange"; +class _Weather extends (external_React_default()).PureComponent { + constructor(props) { + super(props); + this.state = { + contextMenuKeyboard: false, + showContextMenu: false, + url: "https://example.com", + impressionSeen: false, + errorSeen: false + }; + this.setImpressionRef = element => { + this.impressionElement = element; + }; + this.setErrorRef = element => { + this.errorElement = element; + }; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onUpdate = this.onUpdate.bind(this); + this.onProviderClick = this.onProviderClick.bind(this); + } + componentDidMount() { + const { + props + } = this; + if (!props.dispatch) { + return; + } + if (props.document.visibilityState === Weather_VISIBLE) { + // Setup the impression observer once the page is visible. + this.setImpressionObservers(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener(Weather_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + } + this._onVisibilityChange = () => { + if (props.document.visibilityState === Weather_VISIBLE) { + // Setup the impression observer once the page is visible. + this.setImpressionObservers(); + props.document.removeEventListener(Weather_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + } + }; + props.document.addEventListener(Weather_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + } + } + componentWillUnmount() { + // Remove observers on unmount + if (this.observer && this.impressionElement) { + this.observer.unobserve(this.impressionElement); + } + if (this.observer && this.errorElement) { + this.observer.unobserve(this.errorElement); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener(Weather_VISIBILITY_CHANGE_EVENT, this._onVisibilityChange); + } + } + setImpressionObservers() { + if (this.impressionElement) { + this.observer = new IntersectionObserver(this.onImpression.bind(this)); + this.observer.observe(this.impressionElement); + } + if (this.errorElement) { + this.observer = new IntersectionObserver(this.onError.bind(this)); + this.observer.observe(this.errorElement); + } + } + onImpression(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + if (entry) { + if (this.impressionElement) { + this.observer.unobserve(this.impressionElement); + } + this.props.dispatch(actionCreators.OnlyToMain({ + type: actionTypes.WEATHER_IMPRESSION + })); + + // Stop observing since element has been seen + this.setState({ + impressionSeen: true + }); + } + } + } + onError(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + if (entry) { + if (this.errorElement) { + this.observer.unobserve(this.errorElement); + } + this.props.dispatch(actionCreators.OnlyToMain({ + type: actionTypes.WEATHER_LOAD_ERROR + })); + + // Stop observing since element has been seen + this.setState({ + errorSeen: true + }); + } + } + } + openContextMenu(isKeyBoard) { + if (this.props.onUpdate) { + this.props.onUpdate(true); + } + this.setState({ + showContextMenu: true, + contextMenuKeyboard: isKeyBoard + }); + } + onClick(event) { + event.preventDefault(); + this.openContextMenu(false, event); + } + onKeyDown(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openContextMenu(true, event); + } + } + onUpdate(showContextMenu) { + if (this.props.onUpdate) { + this.props.onUpdate(showContextMenu); + } + this.setState({ + showContextMenu + }); + } + onProviderClick() { + this.props.dispatch(actionCreators.OnlyToMain({ + type: actionTypes.WEATHER_OPEN_PROVIDER_URL, + data: { + source: "WEATHER" + } + })); + } + render() { + // Check if weather should be rendered + const isWeatherEnabled = this.props.Prefs.values["system.showWeather"]; + if (!isWeatherEnabled || !this.props.Weather.initialized) { + return false; + } + const { + showContextMenu + } = this.state; + const WEATHER_SUGGESTION = this.props.Weather.suggestions?.[0]; + const { + className, + index, + dispatch, + eventSource, + shouldSendImpressionStats + } = this.props; + const { + props + } = this; + const isContextMenuOpen = this.state.activeCard === index; + const outerClassName = ["weather", className, isContextMenuOpen && "active", props.placeholder && "placeholder"].filter(v => v).join(" "); + const showDetailedView = this.props.Prefs.values["weather.display"] === "detailed"; + + // Note: The temperature units/display options will become secondary menu items + const WEATHER_SOURCE_CONTEXT_MENU_OPTIONS = [...(this.props.Prefs.values["weather.locationSearchEnabled"] ? ["ChangeWeatherLocation"] : []), ...(this.props.Prefs.values["weather.temperatureUnits"] === "f" ? ["ChangeTempUnitCelsius"] : ["ChangeTempUnitFahrenheit"]), ...(this.props.Prefs.values["weather.display"] === "simple" ? ["ChangeWeatherDisplayDetailed"] : ["ChangeWeatherDisplaySimple"]), "HideWeather", "OpenLearnMoreURL"]; + + // Only return the widget if we have data. Otherwise, show error state + if (WEATHER_SUGGESTION) { + return /*#__PURE__*/external_React_default().createElement("div", { + ref: this.setImpressionRef, + className: outerClassName + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherCard" + }, /*#__PURE__*/external_React_default().createElement("a", { + "data-l10n-id": "newtab-weather-see-forecast", + "data-l10n-args": "{\"provider\": \"AccuWeather\"}", + href: WEATHER_SUGGESTION.forecast.url, + className: "weatherInfoLink", + onClick: this.onProviderClick + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherIconCol" + }, /*#__PURE__*/external_React_default().createElement("span", { + className: `weatherIcon iconId${WEATHER_SUGGESTION.current_conditions.icon_id}` + })), /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherText" + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherForecastRow" + }, /*#__PURE__*/external_React_default().createElement("span", { + className: "weatherTemperature" + }, WEATHER_SUGGESTION.current_conditions.temperature[this.props.Prefs.values["weather.temperatureUnits"]], "\xB0", this.props.Prefs.values["weather.temperatureUnits"])), /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherCityRow" + }, /*#__PURE__*/external_React_default().createElement("span", { + className: "weatherCity" + }, WEATHER_SUGGESTION.city_name)), showDetailedView ? /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherDetailedSummaryRow" + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherHighLowTemps" + }, /*#__PURE__*/external_React_default().createElement("span", null, WEATHER_SUGGESTION.forecast.high[this.props.Prefs.values["weather.temperatureUnits"]], "\xB0", this.props.Prefs.values["weather.temperatureUnits"]), /*#__PURE__*/external_React_default().createElement("span", null, "\u2022"), /*#__PURE__*/external_React_default().createElement("span", null, WEATHER_SUGGESTION.forecast.low[this.props.Prefs.values["weather.temperatureUnits"]], "\xB0", this.props.Prefs.values["weather.temperatureUnits"])), /*#__PURE__*/external_React_default().createElement("span", { + className: "weatherTextSummary" + }, WEATHER_SUGGESTION.current_conditions.summary)) : null)), /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherButtonContextMenuWrapper" + }, /*#__PURE__*/external_React_default().createElement("button", { + "aria-haspopup": "true", + onKeyDown: this.onKeyDown, + onClick: this.onClick, + "data-l10n-id": "newtab-menu-section-tooltip", + className: "weatherButtonContextMenu" + }, showContextMenu ? /*#__PURE__*/external_React_default().createElement(LinkMenu, { + dispatch: dispatch, + index: index, + source: eventSource, + onUpdate: this.onUpdate, + options: WEATHER_SOURCE_CONTEXT_MENU_OPTIONS, + site: { + url: "https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page" + }, + link: "https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page", + shouldSendImpressionStats: shouldSendImpressionStats + }) : null))), /*#__PURE__*/external_React_default().createElement("span", { + "data-l10n-id": "newtab-weather-sponsored", + "data-l10n-args": "{\"provider\": \"AccuWeather\"}", + className: "weatherSponsorText" + })); + } + return /*#__PURE__*/external_React_default().createElement("div", { + ref: this.setErrorRef, + className: outerClassName + }, /*#__PURE__*/external_React_default().createElement("div", { + className: "weatherNotAvailable" + }, /*#__PURE__*/external_React_default().createElement("span", { + className: "icon icon-small-spacer icon-info-critical" + }), " ", /*#__PURE__*/external_React_default().createElement("span", { + "data-l10n-id": "newtab-weather-error-not-available" + }))); + } +} +const Weather_Weather = (0,external_ReactRedux_namespaceObject.connect)(state => ({ + Weather: state.Weather, + Prefs: state.Prefs, + IntersectionObserver: globalThis.IntersectionObserver, + document: globalThis.document +}))(_Weather); ;// CONCATENATED MODULE: ./content-src/components/Base/Base.jsx function Base_extends() { Base_extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return Base_extends.apply(this, arguments); } /* This Source Code Form is subject to the terms of the Mozilla Public @@ -9465,6 +9875,7 @@ function Base_extends() { Base_extends = Object.assign ? Object.assign.bind() : + const Base_VISIBLE = "visible"; const Base_VISIBILITY_CHANGE_EVENT = "visibilitychange"; const PrefsButton = ({ @@ -9660,7 +10071,7 @@ class BaseContent extends (external_React_default()).PureComponent { if (activeWallpaper && wallpaperList && name.url) { return /*#__PURE__*/external_React_default().createElement("p", { className: `wallpaper-attribution`, - key: name, + key: name.string, "data-l10n-id": "newtab-wallpaper-attribution", "data-l10n-args": JSON.stringify({ author_string: name.string, @@ -9688,6 +10099,14 @@ class BaseContent extends (external_React_default()).PureComponent { 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 || ""})`); + + // Add helper class to body if user has a wallpaper selected + if (lightWallpaper) { + __webpack_require__.g.document?.body.classList.add("hasWallpaperLight"); + } + if (darkWallpaper) { + __webpack_require__.g.document?.body.classList.add("hasWallpaperDark"); + } } } render() { @@ -9704,6 +10123,7 @@ class BaseContent extends (external_React_default()).PureComponent { const prefs = props.Prefs.values; const activeWallpaper = prefs[`newtabWallpapers.wallpaper-${this.state.colorMode}`]; const wallpapersEnabled = prefs["newtabWallpapers.enabled"]; + const weatherEnabled = prefs.showWeather; const { pocketConfig } = prefs; @@ -9723,10 +10143,12 @@ class BaseContent extends (external_React_default()).PureComponent { showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites, showSponsoredPocketEnabled: prefs.showSponsored, showRecentSavesEnabled: prefs.showRecentSaves, - topSitesRowsCount: prefs.topSitesRows + topSitesRowsCount: prefs.topSitesRows, + weatherEnabled: prefs.showWeather }; const pocketRegion = prefs["feeds.system.topstories"]; const mayHaveSponsoredStories = prefs["system.showSponsored"]; + const mayHaveWeather = prefs["system.showWeather"]; const { mayHaveSponsoredTopSites } = prefs; @@ -9745,6 +10167,7 @@ class BaseContent extends (external_React_default()).PureComponent { pocketRegion: pocketRegion, mayHaveSponsoredTopSites: mayHaveSponsoredTopSites, mayHaveSponsoredStories: mayHaveSponsoredStories, + mayHaveWeather: mayHaveWeather, spocMessageVariant: spocMessageVariant, showing: customizeMenuVisible }), /*#__PURE__*/external_React_default().createElement("div", { @@ -9763,7 +10186,7 @@ class BaseContent extends (external_React_default()).PureComponent { locale: props.App.locale, mayHaveSponsoredStories: mayHaveSponsoredStories, firstVisibleTimestamp: this.state.firstVisibleTimestamp - })) : /*#__PURE__*/external_React_default().createElement(Sections_Sections, null)), /*#__PURE__*/external_React_default().createElement(ConfirmDialog, null), wallpapersEnabled && this.renderWallpaperAttribution()))); + })) : /*#__PURE__*/external_React_default().createElement(Sections_Sections, null)), /*#__PURE__*/external_React_default().createElement(ConfirmDialog, null), wallpapersEnabled && this.renderWallpaperAttribution()), /*#__PURE__*/external_React_default().createElement("aside", null, weatherEnabled && /*#__PURE__*/external_React_default().createElement(ErrorBoundary, null, /*#__PURE__*/external_React_default().createElement(Weather_Weather, null))))); } } BaseContent.defaultProps = { @@ -9775,7 +10198,8 @@ const Base = (0,external_ReactRedux_namespaceObject.connect)(state => ({ Sections: state.Sections, DiscoveryStream: state.DiscoveryStream, Search: state.Search, - Wallpapers: state.Wallpapers + Wallpapers: state.Wallpapers, + Weather: state.Weather }))(_Base); ;// CONCATENATED MODULE: ./content-src/lib/detect-user-session-start.mjs /* This Source Code Form is subject to the terms of the Mozilla Public diff --git a/browser/components/newtab/data/content/assets/glyph-info-critical-16.svg b/browser/components/newtab/data/content/assets/glyph-info-critical-16.svg new file mode 100644 index 0000000000..4fed88fb21 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-info-critical-16.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none"> + <path d="M7.625 16C6.64009 16 5.66482 15.806 4.75487 15.4291C3.84493 15.0522 3.01814 14.4997 2.3217 13.8033C1.62526 13.1069 1.07281 12.2801 0.695904 11.3701C0.318993 10.4602 0.125 9.48491 0.125 8.5C0.125 7.51509 0.318993 6.53982 0.695904 5.62987C1.07281 4.71993 1.62526 3.89314 2.3217 3.1967C3.01814 2.50026 3.84493 1.94781 4.75487 1.5709C5.66482 1.19399 6.64009 1 7.625 1C9.61412 1 11.5218 1.79018 12.9283 3.1967C14.3348 4.60322 15.125 6.51088 15.125 8.5C15.125 10.4891 14.3348 12.3968 12.9283 13.8033C11.5218 15.2098 9.61412 16 7.625 16ZM8.25 5.125C8.25 4.95924 8.18415 4.80027 8.06694 4.68306C7.94973 4.56585 7.79076 4.5 7.625 4.5C7.45924 4.5 7.30027 4.56585 7.18306 4.68306C7.06585 4.80027 7 4.95924 7 5.125V9.563C7 9.72876 7.06585 9.88773 7.18306 10.0049C7.30027 10.1222 7.45924 10.188 7.625 10.188C7.79076 10.188 7.94973 10.1222 8.06694 10.0049C8.18415 9.88773 8.25 9.72876 8.25 9.563V5.125ZM8.25 11.5L8 11.25H7.25L7 11.5V12.25L7.25 12.5H8L8.25 12.25V11.5Z" fill="context-fill"/> +</svg>
\ No newline at end of file diff --git a/browser/components/newtab/karma.mc.config.js b/browser/components/newtab/karma.mc.config.js index 886b19df7b..89887918e0 100644 --- a/browser/components/newtab/karma.mc.config.js +++ b/browser/components/newtab/karma.mc.config.js @@ -188,6 +188,15 @@ module.exports = function (config) { functions: 0, branches: 0, }, + /** + * Weather.jsx is tested via an xpcshell test + */ + "content-src/components/Weather/*.jsx": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, "content-src/components/DiscoveryStreamAdmin/*.jsx": { statements: 0, lines: 0, diff --git a/browser/components/newtab/lib/AboutPreferences.sys.mjs b/browser/components/newtab/lib/AboutPreferences.sys.mjs index 08e0ca422a..7d13214361 100644 --- a/browser/components/newtab/lib/AboutPreferences.sys.mjs +++ b/browser/components/newtab/lib/AboutPreferences.sys.mjs @@ -50,6 +50,26 @@ const PREFS_BEFORE_SECTIONS = () => [ rowsPref: "topSitesRows", eventSource: "TOP_SITES", }, + { + id: "weather", + icon: "chrome://browser/skin/weather/sunny.svg", + pref: { + feed: "showWeather", + titleString: "home-prefs-weather-header", + descString: "home-prefs-weather-description", + learnMore: { + link: { + href: "https://support.mozilla.org/kb/customize-items-on-firefox-new-tab-page", + id: "home-prefs-weather-learn-more-link", + }, + }, + }, + eventSource: "WEATHER", + shouldHidePref: !Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.system.showWeather", + false + ), + }, ]; export class AboutPreferences { @@ -74,7 +94,7 @@ export class AboutPreferences { break; // This is used to open the web extension settings page for an extension case at.OPEN_WEBEXT_SETTINGS: - action._target.browser.ownerGlobal.BrowserOpenAddonsMgr( + action._target.browser.ownerGlobal.BrowserAddonUI.openAddonsMgr( `addons://detail/${encodeURIComponent(action.data)}` ); break; @@ -213,15 +233,13 @@ export class AboutPreferences { linkPref(checkbox, name, "bool"); - // Specially add a link for stories - if (id === "topstories") { - const sponsoredHbox = createAppend("hbox", sectionVbox); - sponsoredHbox.setAttribute("align", "center"); - sponsoredHbox.appendChild(checkbox); + // Specially add a link for Recommended stories and Weather + if (id === "topstories" || id === "weather") { + const hboxWithLink = createAppend("hbox", sectionVbox); + hboxWithLink.appendChild(checkbox); checkbox.classList.add("tail-with-learn-more"); - const link = createAppend("label", sponsoredHbox, { is: "text-link" }); - link.classList.add("learn-sponsored"); + const link = createAppend("label", hboxWithLink, { is: "text-link" }); link.setAttribute("href", sectionData.pref.learnMore.link.href); document.l10n.setAttributes(link, sectionData.pref.learnMore.link.id); } diff --git a/browser/components/newtab/lib/ActivityStream.sys.mjs b/browser/components/newtab/lib/ActivityStream.sys.mjs index fa2d011f11..430707ab5b 100644 --- a/browser/components/newtab/lib/ActivityStream.sys.mjs +++ b/browser/components/newtab/lib/ActivityStream.sys.mjs @@ -37,6 +37,7 @@ ChromeUtils.defineESModuleGetters(lazy, { TopSitesFeed: "resource://activity-stream/lib/TopSitesFeed.sys.mjs", TopStoriesFeed: "resource://activity-stream/lib/TopStoriesFeed.sys.mjs", WallpaperFeed: "resource://activity-stream/lib/WallpaperFeed.sys.mjs", + WeatherFeed: "resource://activity-stream/lib/WeatherFeed.sys.mjs", }); // NB: Eagerly load modules that will be loaded/constructed/initialized in the @@ -57,6 +58,16 @@ function showSpocs({ geo }) { return spocsGeo.includes(geo); } +function showWeather({ geo }) { + const weatherGeoString = + lazy.NimbusFeatures.pocketNewtab.getVariable("regionWeatherConfig") || ""; + const weatherGeo = weatherGeoString + .split(",") + .map(s => s.trim()) + .filter(item => item); + return weatherGeo.includes(geo); +} + // Configure default Activity Stream prefs with a plain `value` or a `getValue` // that computes a value. A `value_local_dev` is used for development defaults. export const PREFS_CONFIG = new Map([ @@ -132,6 +143,50 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "system.showWeather", + { + title: "system.showWeather", + // pref is dynamic + getValue: showWeather, + }, + ], + [ + "showWeather", + { + title: "showWeather", + value: true, + }, + ], + [ + "weather.query", + { + title: "weather.query", + value: "", + }, + ], + [ + "weather.locationSearchEnabled", + { + title: "Enable the option to search for a specific city", + value: false, + }, + ], + [ + "weather.temperatureUnits", + { + title: "Switch the temperature between Celsius and Fahrenheit", + value: "f", + }, + ], + [ + "weather.display", + { + title: + "Toggle the weather widget to include a text summary of the current conditions", + value: "simple", + }, + ], + [ "pocketCta", { title: "Pocket cta and button for logged out users.", @@ -552,6 +607,12 @@ const FEEDS_DATA = [ title: "Handles fetching and managing wallpaper data from RemoteSettings", value: true, }, + { + name: "weatherfeed", + factory: () => new lazy.WeatherFeed(), + title: "Handles fetching and caching weather data", + value: true, + }, ]; const FEEDS_CONFIG = new Map(); diff --git a/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs b/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs index 1e128ec3f2..22a1dea2a9 100644 --- a/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs +++ b/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs @@ -38,6 +38,7 @@ export class ActivityStreamStorage { return { get: this._get.bind(this, storeName), getAll: this._getAll.bind(this, storeName), + getAllKeys: this._getAllKeys.bind(this, storeName), set: this._set.bind(this, storeName), }; } @@ -61,6 +62,12 @@ export class ActivityStreamStorage { ); } + _getAllKeys(storeName) { + return this._requestWrapper(async () => + (await this._getStore(storeName)).getAllKeys() + ); + } + _set(storeName, key, value) { return this._requestWrapper(async () => (await this._getStore(storeName)).put(value, key) @@ -68,7 +75,7 @@ export class ActivityStreamStorage { } _openDatabase() { - return lazy.IndexedDB.open(this.dbName, { version: this.dbVersion }, db => { + return lazy.IndexedDB.open(this.dbName, this.dbVersion, db => { // If provided with array of objectStore names we need to create all the // individual stores this.storeNames.forEach(store => { diff --git a/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs b/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs index bff9f1e04e..e1f5dff6ce 100644 --- a/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs +++ b/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs @@ -564,10 +564,17 @@ export class DiscoveryStreamFeed { } generateFeedUrl(isBff) { + // check for experiment parameters + const hasParameters = lazy.NimbusFeatures.pocketNewtab.getVariable( + "pocketFeedParameters" + ); + if (isBff) { return `https://${Services.prefs.getStringPref( "extensions.pocket.bffApi" - )}/desktop/v1/recommendations?locale=$locale®ion=$region&count=30`; + )}/desktop/v1/recommendations?locale=$locale®ion=$region&count=30${ + hasParameters || "" + }`; } return FEED_URL; } diff --git a/browser/components/newtab/lib/DownloadsManager.sys.mjs b/browser/components/newtab/lib/DownloadsManager.sys.mjs index f6e99e462a..3646ebc73a 100644 --- a/browser/components/newtab/lib/DownloadsManager.sys.mjs +++ b/browser/components/newtab/lib/DownloadsManager.sys.mjs @@ -7,6 +7,7 @@ import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs", FileUtils: "resource://gre/modules/FileUtils.sys.mjs", @@ -166,10 +167,8 @@ export class DownloadsManager { ); }); break; - case at.OPEN_DOWNLOAD_FILE: - const win = action._target.browser.ownerGlobal; - const openWhere = - action.data.event && win.whereToOpenLink(action.data.event); + case at.OPEN_DOWNLOAD_FILE: { + const openWhere = lazy.BrowserUtils.whereToOpenLink(action.data.event); doDownloadAction(download => { lazy.DownloadsCommon.openDownload(download, { // Replace "current" or unknown value with "tab" as the default behavior @@ -180,6 +179,7 @@ export class DownloadsManager { }); }); break; + } case at.UNINIT: this.uninit(); break; diff --git a/browser/components/newtab/lib/PlacesFeed.sys.mjs b/browser/components/newtab/lib/PlacesFeed.sys.mjs index 85679153bd..78e6873b3d 100644 --- a/browser/components/newtab/lib/PlacesFeed.sys.mjs +++ b/browser/components/newtab/lib/PlacesFeed.sys.mjs @@ -24,6 +24,7 @@ const { AboutNewTab } = ChromeUtils.importESModule( const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", @@ -274,7 +275,7 @@ export class PlacesFeed { const win = action._target.browser.ownerGlobal; win.openTrustedLinkIn( urlToOpen, - where || win.whereToOpenLink(event), + where || lazy.BrowserUtils.whereToOpenLink(event), params ); diff --git a/browser/components/newtab/lib/TelemetryFeed.sys.mjs b/browser/components/newtab/lib/TelemetryFeed.sys.mjs index 6cf4dba4ab..2643337674 100644 --- a/browser/components/newtab/lib/TelemetryFeed.sys.mjs +++ b/browser/components/newtab/lib/TelemetryFeed.sys.mjs @@ -114,6 +114,7 @@ const NEWTAB_PING_PREFS = { "feeds.section.topstories": Glean.pocket.enabled, showSponsored: Glean.pocket.sponsoredStoriesEnabled, topSitesRows: Glean.topsites.rows, + showWeather: Glean.newtab.weatherEnabled, }; const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; @@ -932,9 +933,87 @@ export class TelemetryFeed { case at.BLOCK_URL: this.handleBlockUrl(action); break; + case at.WALLPAPER_CLICK: + this.handleWallpaperUserEvent(action); + break; + case at.SET_PREF: + this.handleSetPref(action); + break; + case at.WEATHER_IMPRESSION: + this.handleWeatherUserEvent(action); + break; + case at.WEATHER_LOAD_ERROR: + this.handleWeatherUserEvent(action); + break; + case at.WEATHER_OPEN_PROVIDER_URL: + this.handleWeatherUserEvent(action); + break; + } + } + + handleSetPref(action) { + const prefName = action.data.name; + + // TODO: Migrate this event to handleWeatherUserEvent() + if (prefName === "weather.display") { + const session = this.sessions.get(au.getPortIdOfSender(action)); + + if (!session) { + return; + } + + Glean.newtab.weatherChangeDisplay.record({ + newtab_visit_id: session.session_id, + weather_display_mode: action.data.value, + }); + } + } + + handleWeatherUserEvent(action) { + const session = this.sessions.get(au.getPortIdOfSender(action)); + + if (!session) { + return; + } + + // Weather specific telemtry events can be added and parsed here. + switch (action.type) { + case "WEATHER_IMPRESSION": + Glean.newtab.weatherImpression.record({ + newtab_visit_id: session.session_id, + }); + break; + case "WEATHER_LOAD_ERROR": + Glean.newtab.weatherLoadError.record({ + newtab_visit_id: session.session_id, + }); + break; + case "WEATHER_OPEN_PROVIDER_URL": + Glean.newtab.weatherOpenProviderUrl.record({ + newtab_visit_id: session.session_id, + }); + break; + default: + break; } } + handleWallpaperUserEvent(action) { + const session = this.sessions.get(au.getPortIdOfSender(action)); + + if (!session) { + return; + } + const { data } = action; + const { selected_wallpaper, hadPreviousWallpaper } = data; + // if either of the wallpaper prefs are truthy, they had a previous wallpaper + Glean.newtab.wallpaperClick.record({ + newtab_visit_id: session.session_id, + selected_wallpaper, + hadPreviousWallpaper, + }); + } + handleBlockUrl(action) { const session = this.sessions.get(au.getPortIdOfSender(action)); // TODO: Do we want to not send this unless there's a newtab_visit_id? diff --git a/browser/components/newtab/lib/TopSitesFeed.sys.mjs b/browser/components/newtab/lib/TopSitesFeed.sys.mjs index e259253402..7ab85466c6 100644 --- a/browser/components/newtab/lib/TopSitesFeed.sys.mjs +++ b/browser/components/newtab/lib/TopSitesFeed.sys.mjs @@ -73,7 +73,7 @@ const ROWS_PREF = "topSitesRows"; const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; // The default total number of sponsored top sites to fetch from Contile // and Pocket. -const MAX_NUM_SPONSORED = 2; +const MAX_NUM_SPONSORED = 3; // Nimbus variable for the total number of sponsored top sites including // both Contile and Pocket sources. // The default will be `MAX_NUM_SPONSORED` if this variable is unspecified. @@ -112,7 +112,7 @@ const NIMBUS_VARIABLE_CONTILE_POSITIONS = "contileTopsitesPositions"; const CONTILE_ENDPOINT_PREF = "browser.topsites.contile.endpoint"; const CONTILE_UPDATE_INTERVAL = 15 * 60 * 1000; // 15 minutes // The maximum number of sponsored top sites to fetch from Contile. -const CONTILE_MAX_NUM_SPONSORED = 2; +const CONTILE_MAX_NUM_SPONSORED = 3; const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles"; const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor"; diff --git a/browser/components/newtab/lib/WeatherFeed.sys.mjs b/browser/components/newtab/lib/WeatherFeed.sys.mjs new file mode 100644 index 0000000000..16aa8196af --- /dev/null +++ b/browser/components/newtab/lib/WeatherFeed.sys.mjs @@ -0,0 +1,208 @@ +/* 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, { + MerinoClient: "resource:///modules/MerinoClient.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + PersistentCache: "resource://activity-stream/lib/PersistentCache.sys.mjs", +}); + +import { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.mjs"; + +const CACHE_KEY = "weather_feed"; +const WEATHER_UPDATE_TIME = 10 * 60 * 1000; // 10 minutes +const MERINO_PROVIDER = "accuweather"; + +const PREF_WEATHER_QUERY = "weather.query"; +const PREF_SHOW_WEATHER = "showWeather"; +const PREF_SYSTEM_SHOW_WEATHER = "system.showWeather"; + +/** + * A feature that periodically fetches weather suggestions from Merino for HNT. + */ +export class WeatherFeed { + constructor() { + this.loaded = false; + this.merino = null; + this.suggestions = []; + this.lastUpdated = null; + this.fetchTimer = null; + this.fetchIntervalMs = 30 * 60 * 1000; // 30 minutes + this.timeoutMS = 5000; + this.lastFetchTimeMs = 0; + this.fetchDelayAfterComingOnlineMs = 3000; // 3s + this.cache = this.PersistentCache(CACHE_KEY, true); + } + + async resetCache() { + if (this.cache) { + await this.cache.set("weather", {}); + } + } + + async resetWeather() { + await this.resetCache(); + this.suggestions = []; + this.lastUpdated = null; + } + + isEnabled() { + return ( + this.store.getState().Prefs.values[PREF_SHOW_WEATHER] && + this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_WEATHER] + ); + } + + async init() { + await this.loadWeather(true /* isStartup */); + } + + stopFetching() { + if (!this.merino) { + return; + } + + lazy.clearTimeout(this.fetchTimer); + this.merino = null; + this.suggestions = null; + this.fetchTimer = 0; + } + + /** + * This thin wrapper around the fetch call makes it easier for us to write + * automated tests that simulate responses. + */ + async fetchHelper() { + this.restartFetchTimer(); + const weatherQuery = this.store.getState().Prefs.values[PREF_WEATHER_QUERY]; + let suggestions = []; + try { + suggestions = await this.merino.fetch({ + query: weatherQuery || "", + providers: [MERINO_PROVIDER], + timeoutMs: 5000, + }); + } catch (error) { + // We don't need to do anything with this right now. + } + + // results from the API or empty array if null + this.suggestions = suggestions ?? []; + } + + async fetch(isStartup) { + // Keep a handle on the `MerinoClient` instance that exists at the start of + // this fetch. If fetching stops or this `Weather` instance is uninitialized + // during the fetch, `#merino` will be nulled, and the fetch should stop. We + // can compare `merino` to `this.merino` to tell when this occurs. + this.merino = await this.MerinoClient("HNT_WEATHER_FEED"); + await this.fetchHelper(); + + if (this.suggestions.length) { + this.lastUpdated = this.Date().now(); + await this.cache.set("weather", { + suggestions: this.suggestions, + lastUpdated: this.lastUpdated, + }); + } + + this.update(isStartup); + } + + async loadWeather(isStartup = false) { + const cachedData = (await this.cache.get()) || {}; + const { weather } = cachedData; + + // If we have nothing in cache, or cache has expired, we can make a fresh fetch. + if ( + !weather?.lastUpdated || + !(this.Date().now() - weather.lastUpdated < WEATHER_UPDATE_TIME) + ) { + await this.fetch(isStartup); + } else if (!this.lastUpdated) { + this.suggestions = weather.suggestions; + this.lastUpdated = weather.lastUpdated; + this.update(isStartup); + } + } + + update(isStartup) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.WEATHER_UPDATE, + data: { + suggestions: this.suggestions, + lastUpdated: this.lastUpdated, + }, + meta: { + isStartup, + }, + }) + ); + } + + restartFetchTimer(ms = this.fetchIntervalMs) { + lazy.clearTimeout(this.fetchTimer); + this.fetchTimer = lazy.setTimeout(() => { + this.fetch(); + }, ms); + } + + async onPrefChangedAction(action) { + switch (action.data.name) { + case PREF_WEATHER_QUERY: + await this.loadWeather(); + break; + case PREF_SHOW_WEATHER: + case PREF_SYSTEM_SHOW_WEATHER: + if (this.isEnabled() && action.data.value) { + await this.loadWeather(); + } else { + await this.resetWeather(); + } + break; + } + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + if (this.isEnabled()) { + await this.init(); + } + break; + case at.UNINIT: + await this.resetWeather(); + break; + case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK: + case at.SYSTEM_TICK: + if (this.isEnabled()) { + await this.loadWeather(); + } + break; + case at.PREF_CHANGED: + await this.onPrefChangedAction(action); + break; + } + } +} + +/** + * Creating a thin wrapper around MerinoClient, PersistentCache, and Date. + * This makes it easier for us to write automated tests that simulate responses. + */ +WeatherFeed.prototype.MerinoClient = (...args) => { + return new lazy.MerinoClient(...args); +}; +WeatherFeed.prototype.PersistentCache = (...args) => { + return new lazy.PersistentCache(...args); +}; +WeatherFeed.prototype.Date = () => { + return Date; +}; diff --git a/browser/components/newtab/metrics.yaml b/browser/components/newtab/metrics.yaml index c59247ceef..4a75cd65eb 100644 --- a/browser/components/newtab/metrics.yaml +++ b/browser/components/newtab/metrics.yaml @@ -233,6 +233,132 @@ newtab: send_in_pings: - newtab + wallpaper_click: + type: event + description: > + Recorded when a user clicks on a wallpaper option + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1896004 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1896004 + data_sensitivity: + - interaction + notification_emails: + - nbarrett@mozilla.com + expires: never + extra_keys: + selected_wallpaper: + description: > + Which wallpaper has been selected by the user + Will be the title of a Wallpaper or 'none' for users + that reset the background to default + type: string + had_previous_wallpaper: + description: > + Wheather or not user had a previously set wallpaper + type: boolean + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + + weather_change_display: + type: event + description: > + Recorded when a user changes the weather display. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1895797 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1895797 + data_sensitivity: + - interaction + notification_emails: + - mcrawford@mozilla.com + expires: never + extra_keys: + weather_display_mode: &weather_display_mode + description: > + Which display mode is selected. + type: boolean + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + weather_enabled: + lifetime: application + type: boolean + description: > + Whether the weather widget is enabled on the newtab. + Corresponds to the value of the + `browser.newtabpage.activity-stream.showWeather` pref. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1899340 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1899340 + data_sensitivity: + - interaction + notification_emails: + - mcrawford@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: + - newtab + + weather_open_provider_url: + type: event + description: > + Recorded when a user opens a link to the Weather provider website. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1895797 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1895797 + data_sensitivity: + - interaction + notification_emails: + - mcrawford@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + weather_impression: + type: event + description: > + Recorded when the weather widget is viewed + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1898275 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1895797 + data_sensitivity: + - interaction + notification_emails: + - mcrawford@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + weather_load_error: + type: event + description: > + Recorded when the weather widget is not available + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1898275 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1895797 + data_sensitivity: + - interaction + notification_emails: + - mcrawford@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + newtab.search: enabled: lifetime: application @@ -271,9 +397,10 @@ newtab.handoff_preference: - https://bugzilla.mozilla.org/show_bug.cgi?id=1864496 data_reviews: - https://bugzilla.mozilla.org/show_bug.cgi?id=1864496 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1892148 data_sensitivity: - interaction - expires: 128 + expires: 131 notification_emails: - fx-search-telemetry@mozilla.com diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js b/browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js index a94f1fe055..0b49b3eb69 100644 --- a/browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js +++ b/browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js @@ -28,7 +28,9 @@ add_task(async function test_experiments_api_control() { }); Assert.ok( - !NimbusFeatures.abouthomecache.getVariable("enabled"), + !Services.prefs.getBoolPref( + "browser.startup.homepage.abouthome_cache.enabled" + ), "NimbusFeatures should tell us that the about:home startup cache " + "is disabled" ); @@ -51,7 +53,9 @@ add_task(async function test_experiments_api_control() { }); Assert.ok( - NimbusFeatures.abouthomecache.getVariable("enabled"), + Services.prefs.getBoolPref( + "browser.startup.homepage.abouthome_cache.enabled" + ), "NimbusFeatures should tell us that the about:home startup cache " + "is enabled" ); diff --git a/browser/components/newtab/test/browser/browser_customize_menu_content.js b/browser/components/newtab/test/browser/browser_customize_menu_content.js index ba83f1ff0a..fab032937a 100644 --- a/browser/components/newtab/test/browser/browser_customize_menu_content.js +++ b/browser/components/newtab/test/browser/browser_customize_menu_content.js @@ -1,5 +1,15 @@ "use strict"; +const { WeatherFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/WeatherFeed.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + MerinoTestUtils: "resource://testing-common/MerinoTestUtils.sys.mjs", +}); + +const { WEATHER_SUGGESTION } = MerinoTestUtils; + test_newtab({ async before({ pushPrefs }) { await pushPrefs( @@ -118,6 +128,79 @@ test_newtab({ }); test_newtab({ + async before({ pushPrefs }) { + sinon.stub(WeatherFeed.prototype, "MerinoClient").returns({ + fetch: () => [WEATHER_SUGGESTION], + }); + await pushPrefs( + ["browser.newtabpage.activity-stream.system.showWeather", true], + ["browser.newtabpage.activity-stream.showWeather", false] + ); + }, + test: async function test_render_customizeMenuWeather() { + // Weather Widget Fecthing + function getWeatherWidget() { + return content.document.querySelector(`.weather`); + } + + function promiseWeatherShown() { + return ContentTaskUtils.waitForMutationCondition( + content.document.querySelector("aside"), + { childList: true, subtree: true }, + () => getWeatherWidget() + ); + } + + const WEATHER_PREF = "browser.newtabpage.activity-stream.showWeather"; + + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".personalize-button"), + "Wait for prefs button to load on the newtab page" + ); + + let customizeButton = content.document.querySelector(".personalize-button"); + customizeButton.click(); + + let defaultPos = "matrix(1, 0, 0, 1, 0, 0)"; + await ContentTaskUtils.waitForCondition( + () => + content.getComputedStyle( + content.document.querySelector(".customize-menu") + ).transform === defaultPos, + "Customize Menu should be visible on screen" + ); + + // Test that clicking the weather toggle will make the + // weather widget appear on the newtab page. + // + // We waive XRay wrappers because we want to call the click() + // method defined on the toggle from this context. + let weatherSwitch = Cu.waiveXrays( + content.document.querySelector("#weather-section moz-toggle") + ); + Assert.ok( + !Services.prefs.getBoolPref(WEATHER_PREF), + "Weather pref is turned off" + ); + Assert.ok(!getWeatherWidget(), "Weather widget is not rendered"); + + let sectionShownPromise = promiseWeatherShown(); + weatherSwitch.click(); + await sectionShownPromise; + + Assert.ok(getWeatherWidget(), "Weather widget is rendered"); + }, + async after() { + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.showWeather" + ); + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.system.showWeather" + ); + }, +}); + +test_newtab({ test: async function test_open_close_customizeMenu() { const EventUtils = ContentTaskUtils.getEventUtils(content); await ContentTaskUtils.waitForCondition( 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 0407622cf9..6186ca71fe 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 @@ -10,6 +10,7 @@ const DEFAULT_PROPS = { }, mayHaveSponsoredTopSites: true, mayHaveSponsoredStories: true, + mayHaveWeather: true, pocketRegion: true, dispatch: sinon.stub(), setPref: sinon.stub(), @@ -68,5 +69,9 @@ describe("ContentSection", () => { wrapper.find("#highlights-toggle").prop("data-eventSource"), "HIGHLIGHTS" ); + assert.equal( + wrapper.find("#weather-toggle").prop("data-eventSource"), + "WEATHER" + ); }); }); 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 7f40b66200..006e83e663 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 @@ -67,6 +67,9 @@ describe("DiscoveryStreamAdmin", () => { otherPrefs={{}} state={{ DiscoveryStream: state, + Weather: { + suggestions: [], + }, }} /> ); @@ -90,7 +93,12 @@ describe("DiscoveryStreamAdmin", () => { wrapper = shallow( <DiscoveryStreamAdminUI otherPrefs={{}} - state={{ DiscoveryStream: state }} + state={{ + DiscoveryStream: state, + Weather: { + suggestions: [], + }, + }} /> ); wrapper.instance().onStoryToggle({ id: 12345 }); 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 afb6d6dcd2..796f805444 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 @@ -70,7 +70,9 @@ describe("<DSCard>", () => { }); it("should render DSLinkMenu", () => { - assert.equal(wrapper.children().at(3).type(), DSLinkMenu); + // Note: <DSLinkMenu> component moved from a direct child element of `.ds-card`. See Bug 1893936 + const default_link_menu = wrapper.find(DSLinkMenu); + assert.ok(default_link_menu.exists()); }); it("should start with no .active class", () => { diff --git a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js index a19bf698d9..6555a1b77e 100644 --- a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js +++ b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js @@ -54,17 +54,21 @@ describe("AboutPreferences Feed", () => { instance.onAction(action); assert.calledOnce(action._target.browser.ownerGlobal.openPreferences); }); - it("should call .BrowserOpenAddonsMgr with the extension id on OPEN_WEBEXT_SETTINGS", () => { + it("should call .BrowserAddonUI.openAddonsMgr with the extension id on OPEN_WEBEXT_SETTINGS", () => { const action = { type: at.OPEN_WEBEXT_SETTINGS, data: "foo", _target: { - browser: { ownerGlobal: { BrowserOpenAddonsMgr: sinon.spy() } }, + browser: { + ownerGlobal: { + BrowserAddonUI: { openAddonsMgr: sinon.spy() }, + }, + }, }, }; instance.onAction(action); assert.calledWith( - action._target.browser.ownerGlobal.BrowserOpenAddonsMgr, + action._target.browser.ownerGlobal.BrowserAddonUI.openAddonsMgr, "addons://detail/foo" ); }); @@ -122,8 +126,9 @@ describe("AboutPreferences Feed", () => { const [, structure] = stub.firstCall.args; assert.equal(structure[0].id, "search"); assert.equal(structure[1].id, "topsites"); - assert.equal(structure[2].id, "topstories"); - assert.isEmpty(structure[2].rowsPref); + assert.equal(structure[2].id, "weather"); + assert.equal(structure[3].id, "topstories"); + assert.isEmpty(structure[3].rowsPref); }); }); describe("#renderPreferences", () => { diff --git a/browser/components/newtab/test/unit/lib/ActivityStream.test.js b/browser/components/newtab/test/unit/lib/ActivityStream.test.js index 7921ae2c91..ed00eb8202 100644 --- a/browser/components/newtab/test/unit/lib/ActivityStream.test.js +++ b/browser/components/newtab/test/unit/lib/ActivityStream.test.js @@ -311,6 +311,38 @@ describe("ActivityStream", () => { ); }); }); + describe("discoverystream.region-weather-config", () => { + let getVariableStub; + beforeEach(() => { + getVariableStub = sandbox.stub( + global.NimbusFeatures.pocketNewtab, + "getVariable" + ); + sandbox.stub(global.Region, "home").get(() => "CA"); + }); + it("should turn off weather system pref if no region weather config is set and no geo is set", () => { + getVariableStub.withArgs("regionWeatherConfig").returns(""); + sandbox.stub(global.Region, "home").get(() => ""); + + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("system.showWeather").value); + }); + it("should turn on weather system pref based on region weather config pref", () => { + getVariableStub.withArgs("regionWeatherConfig").returns("CA"); + + as._updateDynamicPrefs(); + + assert.isTrue(PREFS_CONFIG.get("system.showWeather").value); + }); + it("should turn off weather system pref if no region weather config is set", () => { + getVariableStub.withArgs("regionWeatherConfig").returns(""); + + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("system.showWeather").value); + }); + }); describe("_updateDynamicPrefs topstories default value", () => { let getVariableStub; let getBoolPrefStub; diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js index 0b8baef762..fd56a3e185 100644 --- a/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js +++ b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js @@ -47,6 +47,7 @@ describe("ActivityStreamStorage", () => { beforeEach(() => { storeStub = { getAll: sandbox.stub().resolves(), + getAllKeys: sandbox.stub().resolves(), get: sandbox.stub().resolves(), put: sandbox.stub().resolves(), }; @@ -75,6 +76,14 @@ describe("ActivityStreamStorage", () => { assert.calledOnce(storeStub.getAll); assert.deepEqual(result, ["bar"]); }); + it("should return the correct value for getAllKeys", async () => { + storeStub.getAllKeys.resolves(["key1", "key2", "key3"]); + + const result = await testStorage.getAllKeys(); + + assert.calledOnce(storeStub.getAllKeys); + assert.deepEqual(result, ["key1", "key2", "key3"]); + }); it("should query the correct object store", async () => { await testStorage.get(); diff --git a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js index e10a4cbc04..72fc6bd0b8 100644 --- a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js +++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js @@ -3467,6 +3467,22 @@ describe("DiscoveryStreamFeed", () => { "https://bffApi/desktop/v1/recommendations?locale=$locale®ion=$region&count=30" ); }); + it("should update the new feed url with pocketFeedParameters", async () => { + globals.set("NimbusFeatures", { + pocketNewtab: { + getVariable: sandbox.stub(), + }, + }); + global.NimbusFeatures.pocketNewtab.getVariable + .withArgs("pocketFeedParameters") + .returns("&enableRankingByRegion=1"); + await feed.loadLayout(feed.store.dispatch); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal( + layout[0].components[2].feed.url, + "https://bffApi/desktop/v1/recommendations?locale=$locale®ion=$region&count=30&enableRankingByRegion=1" + ); + }); it("should fetch proper data from getComponentFeed", async () => { const fakeCache = {}; sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); diff --git a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js index 5e2979893d..23ee9ffa34 100644 --- a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js +++ b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js @@ -29,6 +29,10 @@ describe("Downloads Manager", () => { showDownloadedFile: sinon.stub(), }); + globals.set("BrowserUtils", { + whereToOpenLink: sinon.stub().returns("current"), + }); + downloadsManager = new DownloadsManager(); downloadsManager.init({ dispatch() {} }); downloadsManager.onDownloadAdded({ diff --git a/browser/components/newtab/test/xpcshell/test_PlacesFeed.js b/browser/components/newtab/test/xpcshell/test_PlacesFeed.js index 78dda7818e..d6f7079d77 100644 --- a/browser/components/newtab/test/xpcshell/test_PlacesFeed.js +++ b/browser/components/newtab/test/xpcshell/test_PlacesFeed.js @@ -424,7 +424,7 @@ add_task(async function test_onAction_OPEN_LINK() { data: { url: "https://foo.com" }, _target: { browser: { - ownerGlobal: { openTrustedLinkIn, whereToOpenLink: () => "current" }, + ownerGlobal: { openTrustedLinkIn }, }, }, }; @@ -524,7 +524,7 @@ add_task(async function test_onAction_OPEN_LINK_pocket() { }, _target: { browser: { - ownerGlobal: { openTrustedLinkIn, whereToOpenLink: () => "current" }, + ownerGlobal: { openTrustedLinkIn }, }, }, }; @@ -551,7 +551,7 @@ add_task(async function test_onAction_OPEN_LINK_not_http() { data: { url: "file:///foo.com" }, _target: { browser: { - ownerGlobal: { openTrustedLinkIn, whereToOpenLink: () => "current" }, + ownerGlobal: { openTrustedLinkIn }, }, }, }; diff --git a/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js b/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js index 4be520fcca..247e08b333 100644 --- a/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js +++ b/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js @@ -2970,9 +2970,10 @@ add_task(async function test_ContileIntegration() { Assert.ok(fetched); // Both "foo" and "bar" should be filtered - Assert.equal(feed._contile.sites.length, 2); + Assert.equal(feed._contile.sites.length, 3); Assert.equal(feed._contile.sites[0].url, "https://www.test.com"); Assert.equal(feed._contile.sites[1].url, "https://test1.com"); + Assert.equal(feed._contile.sites[2].url, "https://test2.com"); } { diff --git a/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js b/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js index 5d13df0eb0..04501dbe53 100644 --- a/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js +++ b/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js @@ -47,6 +47,15 @@ let contileTile3 = { image_size: 200, impression_url: "https://impression_url.com", }; +let contileTile4 = { + id: 75899, + name: "Brand4", + url: "https://www.brand4.com", + click_url: "https://click_url.com", + image_url: "https://contile-images.jpg", + image_size: 200, + impression_url: "https://impression_url.com", +}; let mozSalesTile = [ { label: "MozSales Title", @@ -155,7 +164,12 @@ add_task(async function test_set_contile_tile_to_oversold() { let feed = getTopSitesFeedForTest(sandbox); feed._telemetryUtility.setSponsoredTilesConfigured(); - feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + feed._telemetryUtility.setTiles([ + contileTile1, + contileTile2, + contileTile3, + contileTile4, + ]); let mergedTiles = [ { @@ -170,12 +184,18 @@ add_task(async function test_set_contile_tile_to_oversold() { sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -194,6 +214,12 @@ add_task(async function test_set_contile_tile_to_oversold() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -477,7 +503,12 @@ add_task(async function test_set_tiles_to_dismissed_then_updated() { feed._telemetryUtility.setSponsoredTilesConfigured(); // Step 1: Set initial tiles - feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + feed._telemetryUtility.setTiles([ + contileTile1, + contileTile2, + contileTile3, + contileTile4, + ]); // Step 2: Set all tiles to dismissed feed._telemetryUtility.determineFilteredTilesAndSetToDismissed([]); @@ -495,12 +526,18 @@ add_task(async function test_set_tiles_to_dismissed_then_updated() { sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; // Step 3: Finalize with the updated list of tiles. feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -522,6 +559,12 @@ add_task(async function test_set_tiles_to_dismissed_then_updated() { display_position: null, display_fail_reason: "dismissed", }, + { + advertiser: "brand4", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, ], }; Assert.equal( @@ -537,7 +580,12 @@ add_task(async function test_set_tile_positions_after_updated_list() { feed._telemetryUtility.setSponsoredTilesConfigured(); // Step 1: Set initial tiles - feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + feed._telemetryUtility.setTiles([ + contileTile1, + contileTile2, + contileTile3, + contileTile4, + ]); // Step 2: Set 1 tile to oversold (brand3) let mergedTiles = [ @@ -553,6 +601,12 @@ add_task(async function test_set_tile_positions_after_updated_list() { sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); @@ -570,10 +624,16 @@ add_task(async function test_set_tile_positions_after_updated_list() { sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -592,6 +652,12 @@ add_task(async function test_set_tile_positions_after_updated_list() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -610,7 +676,12 @@ add_task(async function test_set_tile_positions_after_updated_list_all_tiles() { feed._telemetryUtility.setSponsoredTilesConfigured(); // Step 1: Set initial tiles - feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + feed._telemetryUtility.setTiles([ + contileTile1, + contileTile2, + contileTile3, + contileTile4, + ]); // Step 2: Set 1 tile to oversold (brand3) let mergedTiles = [ @@ -626,6 +697,12 @@ add_task(async function test_set_tile_positions_after_updated_list_all_tiles() { sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); @@ -643,10 +720,16 @@ add_task(async function test_set_tile_positions_after_updated_list_all_tiles() { sponsored_position: 2, partner: "amp", }, + { + url: "https://www.replacement3.com", + label: "replacement3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -665,6 +748,12 @@ add_task(async function test_set_tile_positions_after_updated_list_all_tiles() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -684,7 +773,12 @@ add_task( feed._telemetryUtility.setSponsoredTilesConfigured(); // Step 1: Set initial tiles - feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + feed._telemetryUtility.setTiles([ + contileTile1, + contileTile2, + contileTile3, + contileTile4, + ]); // Step 2: Set 1 tile to oversold (brand3) let mergedTiles = [ @@ -700,6 +794,12 @@ add_task( sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); @@ -717,10 +817,16 @@ add_task( sponsored_position: 2, partner: "amp", }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 3, + partner: "amp", + }, ]; feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -739,6 +845,12 @@ add_task( { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -901,7 +1013,7 @@ add_task(async function test_all_tiles_displayed() { await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -961,18 +1073,25 @@ add_task(async function test_set_one_tile_display_fail_reason_to_oversold() { impression_url: "https://www.brand3-impression.com", name: "brand3", }, + { + url: "https://www.brand4.com", + image_url: "images/brnad4-com.png", + click_url: "https://www.brand4-click.com", + impression_url: "https://www.brand4-impression.com", + name: "brand4", + }, ], }), }); const fetched = await feed._contile._fetchSites(); Assert.ok(fetched); - Assert.equal(feed._contile.sites.length, 2); + Assert.equal(feed._contile.sites.length, 3); await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ { @@ -990,6 +1109,12 @@ add_task(async function test_set_one_tile_display_fail_reason_to_oversold() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -1052,7 +1177,7 @@ add_task(async function test_set_one_tile_display_fail_reason_to_dismissed() { await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1129,6 +1254,13 @@ add_task( impression_url: "https://www.brand4-impression.com", name: "brand4", }, + { + url: "https://www.brand5.com", + image_url: "images/brand5-com.png", + click_url: "https://www.brand5-click.com", + impression_url: "https://www.brand5-impression.com", + name: "brand5", + }, ], }), }); @@ -1140,12 +1272,12 @@ add_task( const fetched = await feed._contile._fetchSites(); Assert.ok(fetched); - Assert.equal(feed._contile.sites.length, 2); + Assert.equal(feed._contile.sites.length, 3); await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1170,6 +1302,12 @@ add_task( { advertiser: "brand4", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand5", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -1233,7 +1371,7 @@ add_task( await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1297,6 +1435,13 @@ add_task(async function test_update_tile_count() { impression_url: "https://www.brand3-impression.com", name: "brand3", }, + { + url: "https://www.brand4.com", + image_url: "images/brand4-com.png", + click_url: "https://www.brand4-click.com", + impression_url: "https://www.brand4-impression.com", + name: "brand4", + }, ], }), }); @@ -1304,11 +1449,11 @@ add_task(async function test_update_tile_count() { // 1. Initially the Nimbus pref is set to 2 tiles let fetched = await feed._contile._fetchSites(); Assert.ok(fetched); - Assert.equal(feed._contile.sites.length, 2); + Assert.equal(feed._contile.sites.length, 3); await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1327,6 +1472,12 @@ add_task(async function test_update_tile_count() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -1344,7 +1495,7 @@ add_task(async function test_update_tile_count() { ); setNimbusVariablesForNumTiles(nimbusPocketStub, 3); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); expectedResult = { sponsoredTilesReceived: [ @@ -1363,6 +1514,12 @@ add_task(async function test_update_tile_count() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -1402,6 +1559,12 @@ add_task(async function test_update_tile_count() { display_position: 3, display_fail_reason: null, }, + { + advertiser: "brand4", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, ], }; Assert.equal( @@ -1442,6 +1605,13 @@ add_task(async function test_update_tile_count_sourced_from_cache() { impression_url: "https://www.brand3-impression.com", name: "brand3", }, + { + url: "https://www.brand4.com", + image_url: "images/brand4-com.png", + click_url: "https://www.brand4-click.com", + impression_url: "https://www.brand4-impression.com", + name: "brand4", + }, ]; Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); @@ -1459,11 +1629,11 @@ add_task(async function test_update_tile_count_sourced_from_cache() { // Ensure ContileIntegration._fetchSites is working populate _sites and initilize TelemetryUtility let fetched = await feed._contile._fetchSites(); Assert.ok(fetched); - Assert.equal(feed._contile.sites.length, 3); + Assert.equal(feed._contile.sites.length, 4); await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1482,6 +1652,12 @@ add_task(async function test_update_tile_count_sourced_from_cache() { { advertiser: "brand3", provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", display_position: null, display_fail_reason: "oversold", }, @@ -1499,12 +1675,12 @@ add_task(async function test_update_tile_count_sourced_from_cache() { ); setNimbusVariablesForNumTiles(nimbusPocketStub, 3); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); // 3. Confirm the new count is applied when data pulled from Contile, 3 tiles displayed fetched = await feed._contile._fetchSites(); Assert.ok(fetched); - Assert.equal(feed._contile.sites.length, 3); + Assert.equal(feed._contile.sites.length, 4); await feed._readDefaults(); await feed.getLinksWithDefaults(false); @@ -1530,6 +1706,12 @@ add_task(async function test_update_tile_count_sourced_from_cache() { display_position: 3, display_fail_reason: null, }, + { + advertiser: "brand4", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, ], }; Assert.equal( @@ -1595,7 +1777,7 @@ add_task( await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1636,7 +1818,7 @@ add_task( await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); expectedResult = { sponsoredTilesReceived: [ @@ -1684,7 +1866,7 @@ add_task(async function test_sponsoredTilesReceived_not_set() { await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [] }; Assert.equal( @@ -1744,7 +1926,7 @@ add_task(async function test_telemetry_data_updates() { await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1896,7 +2078,7 @@ add_task(async function test_reset_telemetry_data() { await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ @@ -1934,7 +2116,7 @@ add_task(async function test_reset_telemetry_data() { await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); expectedResult = { sponsoredTilesReceived: [] }; Assert.equal( @@ -1982,17 +2164,24 @@ add_task(async function test_set_telemetry_for_moz_sales_tiles() { impression_url: "https://www.brand2-impression.com", name: "brand2", }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand2", + }, ], }), }); const fetched = await feed._contile._fetchSites(); Assert.ok(fetched); - Assert.equal(feed._contile.sites.length, 2); + Assert.equal(feed._contile.sites.length, 3); await feed._readDefaults(); await feed.getLinksWithDefaults(false); - Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); let expectedResult = { sponsoredTilesReceived: [ { @@ -2008,6 +2197,12 @@ add_task(async function test_set_telemetry_for_moz_sales_tiles() { display_fail_reason: null, }, { + advertiser: "brand3", + provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + { advertiser: "mozsales title", provider: "moz-sales", display_position: null, diff --git a/browser/components/newtab/test/xpcshell/test_WeatherFeed.js b/browser/components/newtab/test/xpcshell/test_WeatherFeed.js new file mode 100644 index 0000000000..2821f4b7d0 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_WeatherFeed.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WeatherFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/WeatherFeed.sys.mjs" +); + +const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + sinon: "resource://testing-common/Sinon.sys.mjs", + MerinoTestUtils: "resource://testing-common/MerinoTestUtils.sys.mjs", +}); + +const { WEATHER_SUGGESTION } = MerinoTestUtils; + +const WEATHER_ENABLED = "browser.newtabpage.activity-stream.showWeather"; +const SYS_WEATHER_ENABLED = + "browser.newtabpage.activity-stream.system.showWeather"; + +add_task(async function test_construction() { + let sandbox = sinon.createSandbox(); + sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ + set: () => {}, + get: () => {}, + }); + + let feed = new WeatherFeed(); + + info("WeatherFeed constructor should create initial values"); + + Assert.ok(feed, "Could construct a WeatherFeed"); + Assert.ok(feed.loaded === false, "WeatherFeed is not loaded"); + Assert.ok(feed.merino === null, "merino is initialized as null"); + Assert.ok( + feed.suggestions.length === 0, + "suggestions is initialized as a array with length of 0" + ); + Assert.ok(feed.fetchTimer === null, "fetchTimer is initialized as null"); + sandbox.restore(); +}); + +add_task(async function test_onAction_INIT() { + let sandbox = sinon.createSandbox(); + sandbox.stub(WeatherFeed.prototype, "MerinoClient").returns({ + get: () => [WEATHER_SUGGESTION], + on: () => {}, + }); + sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ + set: () => {}, + get: () => {}, + }); + const dateNowTestValue = 1; + sandbox.stub(WeatherFeed.prototype, "Date").returns({ + now: () => dateNowTestValue, + }); + + let feed = new WeatherFeed(); + + Services.prefs.setBoolPref(WEATHER_ENABLED, true); + Services.prefs.setBoolPref(SYS_WEATHER_ENABLED, true); + + sandbox.stub(feed, "isEnabled").returns(true); + + sandbox.stub(feed, "fetchHelper"); + feed.suggestions = [WEATHER_SUGGESTION]; + + feed.store = { + dispatch: sinon.spy(), + }; + + info("WeatherFeed.onAction INIT should initialize Weather"); + + await feed.onAction({ + type: at.INIT, + }); + + Assert.ok(feed.store.dispatch.calledOnce); + Assert.ok( + feed.store.dispatch.calledWith( + ac.BroadcastToContent({ + type: at.WEATHER_UPDATE, + data: { + suggestions: [WEATHER_SUGGESTION], + lastUpdated: dateNowTestValue, + }, + meta: { + isStartup: true, + }, + }) + ) + ); + Services.prefs.clearUserPref(WEATHER_ENABLED); + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/xpcshell.toml b/browser/components/newtab/test/xpcshell/xpcshell.toml index 13c11b0541..567927c31c 100644 --- a/browser/components/newtab/test/xpcshell/xpcshell.toml +++ b/browser/components/newtab/test/xpcshell/xpcshell.toml @@ -28,3 +28,5 @@ support-files = ["../schemas/*.schema.json"] ["test_TopSitesFeed_glean.js"] ["test_WallpaperFeed.js"] + +["test_WeatherFeed.js"] diff --git a/browser/components/places/PlacesUIUtils.sys.mjs b/browser/components/places/PlacesUIUtils.sys.mjs index b9e9efe70e..288877b55d 100644 --- a/browser/components/places/PlacesUIUtils.sys.mjs +++ b/browser/components/places/PlacesUIUtils.sys.mjs @@ -12,6 +12,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs", + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", @@ -199,22 +200,25 @@ let InternalFaviconLoader = { win.addEventListener("unload", unloadHandler, true); } - // First we do the actual setAndFetch call: - let loadType = lazy.PrivateBrowsingUtils.isWindowPrivate(win) - ? lazy.PlacesUtils.favicons.FAVICON_LOAD_PRIVATE - : lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE; let callback = this._makeCompletionCallback(win, innerWindowID); - - if (iconURI && iconURI.schemeIs("data")) { - expiration = lazy.PlacesUtils.toPRTime(expiration); - lazy.PlacesUtils.favicons.replaceFaviconDataFromDataURL( + if (iconURI?.schemeIs("data")) { + lazy.PlacesUtils.favicons.setFaviconForPage( + pageURI, uri, - iconURI.spec, - expiration, - principal + iconURI, + lazy.PlacesUtils.toPRTime(expiration), + () => { + callback.onComplete(uri); + } ); + return; } + // First we do the actual setAndFetch call: + let loadType = lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ? lazy.PlacesUtils.favicons.FAVICON_LOAD_PRIVATE + : lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE; + let request = lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( pageURI, uri, @@ -464,7 +468,7 @@ class BookmarkState { }) ); break; - case "tags": + case "tags": { const newTags = value.filter( tag => !this._originalState.tags.includes(tag) ); @@ -488,6 +492,7 @@ class BookmarkState { ); } break; + } case "keyword": transactions.push( lazy.PlacesTransactions.EditKeyword({ @@ -982,7 +987,7 @@ export var PlacesUIUtils = { // whereToOpenLink doesn't return "window" when there's no browser window // open (Bug 630255). var where = browserWindow - ? browserWindow.whereToOpenLink(aEvent, false, true) + ? lazy.BrowserUtils.whereToOpenLink(aEvent, false, true) : "window"; if (where == "window") { // There is no browser window open, thus open a new one. @@ -1073,7 +1078,7 @@ export var PlacesUIUtils = { openNodeWithEvent: function PUIU_openNodeWithEvent(aNode, aEvent) { let window = aEvent.target.ownerGlobal; - let where = window.whereToOpenLink(aEvent, false, true); + let where = lazy.BrowserUtils.whereToOpenLink(aEvent, false, true); if (this.loadBookmarksInTabs && lazy.PlacesUtils.nodeIsBookmark(aNode)) { if (where == "current" && !aNode.uri.startsWith("javascript:")) { where = "tab"; @@ -1687,7 +1692,7 @@ export var PlacesUIUtils = { doCommand(command) { let window = this.triggerNode.ownerGlobal; switch (command) { - case "placesCmd_copy": + case "placesCmd_copy": { // This is a little hacky, but there is a lot of code in Places that handles // clipboard stuff, so it's easier to reuse. let node = {}; @@ -1732,6 +1737,7 @@ export var PlacesUIUtils = { Ci.nsIClipboard.kGlobalClipboard ); break; + } case "placesCmd_open:privatewindow": window.openTrustedLinkIn(this.triggerNode.link, "window", { private: true, diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js index 6eaa129961..f46d696908 100644 --- a/browser/components/places/content/controller.js +++ b/browser/components/places/content/controller.js @@ -1436,7 +1436,7 @@ PlacesController.prototype = { let documentUrl = document.documentURI.toLowerCase(); if (documentUrl.endsWith("browser.xhtml")) { // We're in a menu or a panel. - window.SidebarUI._show("viewBookmarksSidebar").then(() => { + window.SidebarController._show("viewBookmarksSidebar").then(() => { let theSidebar = document.getElementById("sidebar"); theSidebar.contentDocument .getElementById("bookmarks-view") 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 228fea654e..f2f47ff4b4 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 @@ -386,7 +386,9 @@ add_task(async function test_sidebar_folder_contextmenu_contents() { tree.selectItems([folder.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -396,7 +398,7 @@ add_task(async function test_sidebar_folder_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -430,7 +432,9 @@ add_task(async function test_sidebar_multiple_folders_contextmenu_contents() { tree.selectItems([folder1.guid, folder2.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -440,7 +444,7 @@ add_task(async function test_sidebar_multiple_folders_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -473,7 +477,9 @@ add_task(async function test_sidebar_bookmark_contextmenu_contents() { tree.selectItems([bookmark.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -483,7 +489,7 @@ add_task(async function test_sidebar_bookmark_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -513,13 +519,17 @@ add_task(async function test_sidebar_bookmark_search_contextmenu_contents() { info("Checking bookmark sidebar menu contents in search context"); // Perform a search first let searchBox = - SidebarUI.browser.contentDocument.getElementById("search-box"); + SidebarController.browser.contentDocument.getElementById( + "search-box" + ); searchBox.value = SECOND_BOOKMARK_TITLE; searchBox.doCommand(); tree.selectItems([bookmark.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -529,7 +539,7 @@ add_task(async function test_sidebar_bookmark_search_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -641,7 +651,9 @@ add_task(async function test_sidebar_mixedselection_contextmenu_contents() { tree.selectItems([bookmark.guid, folder.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -651,7 +663,7 @@ add_task(async function test_sidebar_mixedselection_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -679,7 +691,9 @@ add_task(async function test_sidebar_multiple_bookmarks_contextmenu_contents() { tree.selectItems([bookmark.guid, bookmark2.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -689,7 +703,7 @@ add_task(async function test_sidebar_multiple_bookmarks_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -714,7 +728,9 @@ add_task(async function test_sidebar_multiple_links_contextmenu_contents() { tree.selectAll(); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -724,7 +740,7 @@ add_task(async function test_sidebar_multiple_links_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); @@ -750,7 +766,9 @@ add_task(async function test_sidebar_mixed_bookmarks_contextmenu_contents() { tree.selectItems([bookmark.guid, folder.guid]); let contextMenu = - SidebarUI.browser.contentDocument.getElementById("placesContext"); + SidebarController.browser.contentDocument.getElementById( + "placesContext" + ); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, "popupshown" @@ -760,7 +778,7 @@ add_task(async function test_sidebar_mixed_bookmarks_contextmenu_contents() { return contextMenu; }, optionItems, - SidebarUI.browser.contentDocument + SidebarController.browser.contentDocument ); }); }); diff --git a/browser/components/places/tests/browser/browser_bookmarksProperties.js b/browser/components/places/tests/browser/browser_bookmarksProperties.js index cfa9e6c581..b1147c3541 100644 --- a/browser/components/places/tests/browser/browser_bookmarksProperties.js +++ b/browser/components/places/tests/browser/browser_bookmarksProperties.js @@ -156,7 +156,7 @@ gTests.push({ }, finish() { - SidebarUI.hide(); + SidebarController.hide(); }, async cleanup() { @@ -282,7 +282,7 @@ gTests.push({ }, finish() { - SidebarUI.hide(); + SidebarController.hide(); }, async cleanup() { @@ -399,7 +399,7 @@ gTests.push({ }, finish() { - SidebarUI.hide(); + SidebarController.hide(); }, async cleanup() { @@ -450,7 +450,7 @@ function execute_test_in_sidebar(test) { }, { capture: true, once: true } ); - SidebarUI.show(test.sidebar); + SidebarController.show(test.sidebar); }); } diff --git a/browser/components/places/tests/browser/browser_check_correct_controllers.js b/browser/components/places/tests/browser/browser_check_correct_controllers.js index 80095823e1..d19939b98b 100644 --- a/browser/components/places/tests/browser/browser_check_correct_controllers.js +++ b/browser/components/places/tests/browser/browser_check_correct_controllers.js @@ -32,7 +32,7 @@ add_task(async function test() { let sidebar = await promiseLoadedSidebar("viewBookmarksSidebar"); registerCleanupFunction(() => { - SidebarUI.hide(); + SidebarController.hide(); }); // Focus the tree and check if its controller is returned. @@ -109,6 +109,6 @@ function promiseLoadedSidebar(cmd) { { capture: true, once: true } ); - SidebarUI.show(cmd); + SidebarController.show(cmd); }); } diff --git a/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js b/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js index 8d4d650984..47c53e6027 100644 --- a/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js +++ b/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js @@ -11,7 +11,7 @@ registerCleanupFunction(async () => { CustomizableUI.setToolbarVisibility("PersonalToolbar", false); CustomizableUI.removeWidgetFromArea("library-button"); - SidebarUI.hide(); + SidebarController.hide(); }); async function selectAppMenuView(buttonId, viewId) { diff --git a/browser/components/places/tests/browser/browser_sidebar_on_customization.js b/browser/components/places/tests/browser/browser_sidebar_on_customization.js index 6e97f81dd3..e5704e9d04 100644 --- a/browser/components/places/tests/browser/browser_sidebar_on_customization.js +++ b/browser/components/places/tests/browser/browser_sidebar_on_customization.js @@ -30,9 +30,9 @@ add_setup(async function () { add_task(async function test_open_sidebar_and_customize() { await withSidebarTree("bookmarks", async tree => { async function checkTreeIsFunctional() { - Assert.ok(SidebarUI.isOpen, "Sidebar is open"); + Assert.ok(SidebarController.isOpen, "Sidebar is open"); Assert.ok( - BrowserTestUtils.isVisible(SidebarUI.browser), + BrowserTestUtils.isVisible(SidebarController.browser), "sidebar browser is visible" ); Assert.ok(tree.view.result, "View result is defined"); @@ -49,7 +49,7 @@ add_task(async function test_open_sidebar_and_customize() { await promiseCustomizeStart(); Assert.ok( - !BrowserTestUtils.isVisible(SidebarUI.browser), + !BrowserTestUtils.isVisible(SidebarController.browser), "sidebar browser is hidden" ); Assert.ok(tree.view.result, "View result is defined"); diff --git a/browser/components/places/tests/browser/browser_sidebarpanels_click.js b/browser/components/places/tests/browser/browser_sidebarpanels_click.js index 9a1b039e78..f107eb76d5 100644 --- a/browser/components/places/tests/browser/browser_sidebarpanels_click.js +++ b/browser/components/places/tests/browser/browser_sidebarpanels_click.js @@ -31,7 +31,7 @@ add_task(async function test_sidebarpanels_click() { false, "Unexpected sidebar found - a previous test failed to cleanup correctly" ); - SidebarUI.hide(); + SidebarController.hide(); } // Ensure history is clean before starting the test. @@ -137,7 +137,7 @@ async function testPlacesPanel(testInfo) { await promiseAlert; executeSoon(async function () { - SidebarUI.hide(); + SidebarController.hide(); await testInfo.cleanup(); resolve(); }); @@ -147,7 +147,7 @@ async function testPlacesPanel(testInfo) { ); }); - SidebarUI.show(testInfo.sidebarName); + SidebarController.show(testInfo.sidebarName); return promise; } diff --git a/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js b/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js index 02720cfa2e..c4f6912044 100644 --- a/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js +++ b/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js @@ -11,7 +11,7 @@ const TEST_TITLE = "Test Bookmark"; let appMenuButton = document.getElementById("PanelUI-menu-button"); let bookmarksAppMenu = document.getElementById("PanelUI-bookmarks"); -let sidebarWasAlreadyOpen = SidebarUI.isOpen; +let sidebarWasAlreadyOpen = SidebarController.isOpen; const { CustomizableUITestUtils } = ChromeUtils.importESModule( "resource://testing-common/CustomizableUITestUtils.sys.mjs" @@ -86,7 +86,7 @@ add_task(async function toolbarBookmarkShowInFolder() { // Cleanup await PlacesUtils.bookmarks.eraseEverything(); if (!sidebarWasAlreadyOpen) { - SidebarUI.hide(); + SidebarController.hide(); } await gCUITestUtils.hideMainMenu(); }); diff --git a/browser/components/places/tests/browser/browser_views_iconsupdate.js b/browser/components/places/tests/browser/browser_views_iconsupdate.js index 1799a9665b..d1e5ea83ea 100644 --- a/browser/components/places/tests/browser/browser_views_iconsupdate.js +++ b/browser/components/places/tests/browser/browser_views_iconsupdate.js @@ -28,9 +28,9 @@ add_task(async function () { let promiseSidebarLoaded = new Promise(resolve => { sidebar.addEventListener("load", resolve, { capture: true, once: true }); }); - SidebarUI.show("viewBookmarksSidebar"); + SidebarController.show("viewBookmarksSidebar"); registerCleanupFunction(() => { - SidebarUI.hide(); + SidebarController.hide(); }); await promiseSidebarLoaded; diff --git a/browser/components/places/tests/browser/head.js b/browser/components/places/tests/browser/head.js index 5459e6f924..2decd11da9 100644 --- a/browser/components/places/tests/browser/head.js +++ b/browser/components/places/tests/browser/head.js @@ -395,7 +395,7 @@ var withSidebarTree = async function (type, taskFn) { }); let sidebarId = type == "bookmarks" ? "viewBookmarksSidebar" : "viewHistorySidebar"; - SidebarUI.show(sidebarId); + SidebarController.show(sidebarId); await sidebarLoadedPromise; let treeId = type == "bookmarks" ? "bookmarks-view" : "historyTree"; @@ -406,7 +406,7 @@ var withSidebarTree = async function (type, taskFn) { try { await taskFn(tree); } finally { - SidebarUI.hide(); + SidebarController.hide(); } }; diff --git a/browser/components/preferences/dialogs/connection.js b/browser/components/preferences/dialogs/connection.js index 33e8deb279..6d2623d65f 100644 --- a/browser/components/preferences/dialogs/connection.js +++ b/browser/components/preferences/dialogs/connection.js @@ -132,10 +132,11 @@ var gConnectionsDialog = { if ("@mozilla.org/system-proxy-settings;1" in Cc) { document.getElementById("systemPref").removeAttribute("hidden"); - var systemWpadAllowed = Preferences.get( - "network.proxy.system_wpad.allowed" + var systemWpadAllowed = Services.prefs.getBoolPref( + "network.proxy.system_wpad.allowed", + false ); - if (systemWpadAllowed && Services.appinfo.OS == "WINNT") { + if (systemWpadAllowed && AppConstants.platform == "win") { document.getElementById("systemWpad").removeAttribute("hidden"); } } diff --git a/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml b/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml index 2b041c4802..83bd09c0c3 100644 --- a/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml +++ b/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml @@ -26,7 +26,6 @@ rel="localization" href="browser/preferences/preferences.ftl" /> - <html:link rel="localization" href="toolkit/branding/accounts.ftl" /> </linkset> <script src="chrome://global/content/preferencesBindings.js" /> <script src="chrome://browser/content/preferences/dialogs/syncChooseWhatToSync.js" /> diff --git a/browser/components/preferences/fxaPairDevice.xhtml b/browser/components/preferences/fxaPairDevice.xhtml index ad068b143d..ce988d289a 100644 --- a/browser/components/preferences/fxaPairDevice.xhtml +++ b/browser/components/preferences/fxaPairDevice.xhtml @@ -28,7 +28,6 @@ rel="localization" href="browser/preferences/fxaPairDevice.ftl" /> - <html:link rel="localization" href="toolkit/branding/accounts.ftl" /> </linkset> <script src="chrome://browser/content/preferences/fxaPairDevice.js" /> diff --git a/browser/components/preferences/main.inc.xhtml b/browser/components/preferences/main.inc.xhtml index 1eb2189c41..fcf015a3b9 100644 --- a/browser/components/preferences/main.inc.xhtml +++ b/browser/components/preferences/main.inc.xhtml @@ -81,6 +81,14 @@ </hbox> </groupbox> +<groupbox id="dataBackupGroup" data-category="paneGeneral" hidden="true" + data-hidden-from-search="true"> + <label><html:h2 data-l10n-id="settings-data-backup-header"/></label> + <hbox flex="1"> + <html:backup-settings /> + </hbox> +</groupbox> + <!-- Tab preferences --> <groupbox data-category="paneGeneral" hidden="true"> @@ -384,7 +392,7 @@ <vbox id="translationsGroup" hidden="true" data-subcategory="translations"> <label><html:h2 data-l10n-id="translations-manage-header"/></label> <hbox id="translations-manage-description" align="center"> - <description flex="1" data-l10n-id="translations-manage-intro"/> + <description flex="1" data-l10n-id="translations-manage-intro-2"/> <button id="translations-manage-settings-button" is="highlightable-button" class="accessory-button" @@ -393,9 +401,9 @@ <vbox> <html:div id="translations-manage-install-list" hidden="true"> <hbox class="translations-manage-language"> - <label data-l10n-id="translations-manage-install-description"></label> + <label data-l10n-id="translations-manage-download-description"></label> <button id="translations-manage-install-all" - data-l10n-id="translations-manage-language-install-all-button"></button> + data-l10n-id="translations-manage-language-download-all-button"></button> <button id="translations-manage-delete-all" data-l10n-id="translations-manage-language-remove-all-button"></button> </hbox> diff --git a/browser/components/preferences/main.js b/browser/components/preferences/main.js index b578e0e29f..22bd3fe174 100644 --- a/browser/components/preferences/main.js +++ b/browser/components/preferences/main.js @@ -430,19 +430,29 @@ var gMainPane = { "command", gMainPane.onWindowsLaunchOnLoginChange ); - NimbusFeatures.windowsLaunchOnLogin.recordExposureEvent({ - once: true, - }); // We do a check here for startWithLastProfile as we could // have disabled the pref for the user before they're ever // exposed to the experiment on a new profile. + // If we're using MSIX, we don't show the checkbox as MSIX + // can't write to the registry. if ( - NimbusFeatures.windowsLaunchOnLogin.getVariable("enabled") && Cc["@mozilla.org/toolkit/profile-service;1"].getService( Ci.nsIToolkitProfileService - ).startWithLastProfile + ).startWithLastProfile && + !Services.sysinfo.getProperty("hasWinPackageId", false) ) { - document.getElementById("windowsLaunchOnLoginBox").hidden = false; + NimbusFeatures.windowsLaunchOnLogin.recordExposureEvent({ + once: true, + }); + + if ( + Services.prefs.getBoolPref( + "browser.startup.windowsLaunchOnLogin.enabled", + false + ) + ) { + document.getElementById("windowsLaunchOnLoginBox").hidden = false; + } } } gMainPane.updateBrowserStartupUI = @@ -521,6 +531,14 @@ var gMainPane = { document.getElementById("dataMigrationGroup").remove(); } + if ( + Services.prefs.getBoolPref("browser.backup.preferences.ui.enabled", false) + ) { + let backupGroup = document.getElementById("dataBackupGroup"); + backupGroup.hidden = false; + backupGroup.removeAttribute("data-hidden-from-search"); + } + // For media control toggle button, we support it on Windows, macOS and // gtk-based Linux. if ( @@ -1130,7 +1148,7 @@ var gMainPane = { this.markAllDownloadPhases("downloaded"); } catch (error) { TranslationsView.showError( - "translations-manage-error-install", + "translations-manage-error-download", error ); await this.reloadDownloadPhases(); @@ -1167,7 +1185,7 @@ var gMainPane = { this.updateDownloadPhase(langTag, "downloaded"); } catch (error) { TranslationsView.showError( - "translations-manage-error-install", + "translations-manage-error-download", error ); this.updateDownloadPhase(langTag, "uninstalled"); @@ -1221,7 +1239,7 @@ var gMainPane = { document.l10n.setAttributes( downloadButton, - "translations-manage-language-install-button" + "translations-manage-language-download-button" ); document.l10n.setAttributes( deleteButton, @@ -2237,9 +2255,8 @@ var gMainPane = { // 1/2/4 values set via about:config should persist return this._storedFullKeyboardNavigation; } - // When the checkbox is unchecked, this pref shouldn't exist - // at all. - return undefined; + // When the checkbox is unchecked, default to just text controls. + return 1; }, /** @@ -4202,7 +4219,7 @@ const AppearanceChooser = { e.preventDefault(); break; case "web-appearance-manage-themes-link": - window.browsingContext.topChromeWindow.BrowserOpenAddonsMgr( + window.browsingContext.topChromeWindow.BrowserAddonUI.openAddonsMgr( "addons://list/theme" ); e.preventDefault(); diff --git a/browser/components/preferences/preferences.js b/browser/components/preferences/preferences.js index 0cd498fd96..34d6a9b11d 100644 --- a/browser/components/preferences/preferences.js +++ b/browser/components/preferences/preferences.js @@ -264,7 +264,7 @@ function init_all() { return; } let mainWindow = window.browsingContext.topChromeWindow; - mainWindow.BrowserOpenAddonsMgr(); + mainWindow.BrowserAddonUI.openAddonsMgr(); }); document.dispatchEvent( @@ -575,8 +575,9 @@ async function confirmRestartPrompt( break; } - let buttonIndex = Services.prompt.confirmEx( - window, + let button = await Services.prompt.asyncConfirmEx( + window.browsingContext, + Ci.nsIPrompt.MODAL_TYPE_CONTENT, title, msg, buttonFlags, @@ -587,6 +588,8 @@ async function confirmRestartPrompt( {} ); + let buttonIndex = button.get("buttonNumClicked"); + // If we have the second confirmation dialog for restart, see if the user // cancels out at that point. if (buttonIndex == CONFIRM_RESTART_PROMPT_RESTART_NOW) { diff --git a/browser/components/preferences/preferences.xhtml b/browser/components/preferences/preferences.xhtml index 64062f1f77..4b1a62b578 100644 --- a/browser/components/preferences/preferences.xhtml +++ b/browser/components/preferences/preferences.xhtml @@ -49,7 +49,6 @@ <link rel="localization" href="browser/preferences/fonts.ftl"/> <link rel="localization" href="browser/preferences/moreFromMozilla.ftl"/> <link rel="localization" href="browser/preferences/preferences.ftl"/> - <link rel="localization" href="toolkit/branding/accounts.ftl"/> <link rel="localization" href="toolkit/branding/brandings.ftl"/> <link rel="localization" href="toolkit/featuregates/features.ftl"/> @@ -69,6 +68,7 @@ <link rel="localization" href="browser/translations.ftl"/> <link rel="localization" href="preview/translations.ftl"/> <link rel="localization" href="preview/enUS-searchFeatures.ftl"/> + <link rel="localization" href="preview/backupSettings.ftl"/> <link rel="localization" href="security/certificates/certManager.ftl"/> <link rel="localization" href="security/certificates/deviceManager.ftl"/> <link rel="localization" href="toolkit/updates/history.ftl"/> @@ -87,6 +87,7 @@ <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> + <script type="module" src="chrome://browser/content/backup/backup-settings.mjs"></script> </head> <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" diff --git a/browser/components/preferences/privacy.inc.xhtml b/browser/components/preferences/privacy.inc.xhtml index 5c45771bc2..e93a027a0f 100644 --- a/browser/components/preferences/privacy.inc.xhtml +++ b/browser/components/preferences/privacy.inc.xhtml @@ -552,6 +552,12 @@ </hbox> </vbox> <vbox> + <hbox id="osReauthRow" align="center"> + <checkbox id="osReauthCheckbox" + data-l10n-id="forms-os-reauth"/> + </hbox> + </vbox> + <vbox> <hbox id="masterPasswordRow" align="center"> <checkbox id="useMasterPassword" data-l10n-id="forms-primary-pw-use" diff --git a/browser/components/preferences/privacy.js b/browser/components/preferences/privacy.js index 89fed04e21..2d6fe7cacd 100644 --- a/browser/components/preferences/privacy.js +++ b/browser/components/preferences/privacy.js @@ -60,6 +60,10 @@ ChromeUtils.defineLazyGetter(this, "AlertsServiceDND", function () { } }); +ChromeUtils.defineLazyGetter(lazy, "AboutLoginsL10n", () => { + return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]); +}); + XPCOMUtils.defineLazyServiceGetter( lazy, "gParentalControlsService", @@ -69,13 +73,6 @@ XPCOMUtils.defineLazyServiceGetter( XPCOMUtils.defineLazyPreferenceGetter( this, - "OS_AUTH_ENABLED", - "signon.management.page.os-auth.enabled", - true -); - -XPCOMUtils.defineLazyPreferenceGetter( - this, "gIsFirstPartyIsolated", "privacy.firstparty.isolate", false @@ -1053,6 +1050,7 @@ var gPrivacyPane = { this._initPasswordGenerationUI(); this._initRelayIntegrationUI(); this._initMasterPasswordUI(); + this._initOSAuthentication(); this.initListenersForExtensionControllingPasswordManager(); @@ -2863,8 +2861,7 @@ var gPrivacyPane = { // OS reauthenticate functionality is not available on Linux yet (bug 1527745) if ( !LoginHelper.isPrimaryPasswordSet() && - OS_AUTH_ENABLED && - OSKeyStore.canReauth() + LoginHelper.getOSAuthEnabled(LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF) ) { // Uses primary-password-os-auth-dialog-message-win and // primary-password-os-auth-dialog-message-macosx via concatenation: @@ -2961,6 +2958,54 @@ var gPrivacyPane = { this._updateRelayIntegrationUI(); }, + async _toggleOSAuth() { + let osReauthCheckbox = document.getElementById("osReauthCheckbox"); + + const messageText = await lazy.AboutLoginsL10n.formatValue( + "about-logins-os-auth-dialog-message" + ); + const captionText = await lazy.AboutLoginsL10n.formatValue( + "about-logins-os-auth-dialog-caption" + ); + let win = + osReauthCheckbox.ownerGlobal.docShell.chromeEventHandler.ownerGlobal; + + // Calling OSKeyStore.ensureLoggedIn() instead of LoginHelper.verifyOSAuth() + // since we want to authenticate user each time this stting is changed. + let isAuthorized = ( + await OSKeyStore.ensureLoggedIn(messageText, captionText, win, false) + ).authenticated; + if (!isAuthorized) { + osReauthCheckbox.checked = !osReauthCheckbox.checked; + return; + } + + // If osReauthCheckbox is checked enable osauth. + LoginHelper.setOSAuthEnabled( + LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF, + osReauthCheckbox.checked + ); + }, + + _initOSAuthentication() { + let osReauthCheckbox = document.getElementById("osReauthCheckbox"); + if (!OSKeyStore.canReauth()) { + osReauthCheckbox.hidden = true; + return; + } + + osReauthCheckbox.setAttribute( + "checked", + LoginHelper.getOSAuthEnabled(LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF) + ); + + setEventListener( + "osReauthCheckbox", + "command", + gPrivacyPane._toggleOSAuth.bind(gPrivacyPane) + ); + }, + /** * Shows the sites where the user has saved passwords and the associated login * information. @@ -3227,8 +3272,8 @@ var gPrivacyPane = { initDataCollection() { if ( !AppConstants.MOZ_DATA_REPORTING && - !NimbusFeatures.majorRelease2022.getVariable( - "feltPrivacyShowPreferencesSection" + !Services.prefs.getBoolPref( + "browser.privacySegmentation.preferences.show" ) ) { // Nothing to control in the data collection section, remove it. @@ -3255,16 +3300,19 @@ var gPrivacyPane = { // Section visibility let section = document.getElementById("privacySegmentationSection"); let updatePrivacySegmentationSectionVisibilityState = () => { - section.hidden = !NimbusFeatures.majorRelease2022.getVariable( - "feltPrivacyShowPreferencesSection" + section.hidden = !Services.prefs.getBoolPref( + "browser.privacySegmentation.preferences.show" ); }; - NimbusFeatures.majorRelease2022.onUpdate( + Services.prefs.addObserver( + "browser.privacySegmentation.preferences.show", updatePrivacySegmentationSectionVisibilityState ); + window.addEventListener("unload", () => { - NimbusFeatures.majorRelease2022.offUpdate( + Services.prefs.removeObserver( + "browser.privacySegmentation.preferences.show", updatePrivacySegmentationSectionVisibilityState ); }); diff --git a/browser/components/preferences/tests/browser.toml b/browser/components/preferences/tests/browser.toml index 523110dbc9..07d9cc2880 100644 --- a/browser/components/preferences/tests/browser.toml +++ b/browser/components/preferences/tests/browser.toml @@ -72,6 +72,9 @@ skip-if = [ "verify && debug && os == 'mac'", ] +["browser_connection_system_wpad.js"] +run-if = ["os == 'win'"] + ["browser_connection_valid_hostname.js"] ["browser_containers_name_input.js"] @@ -293,4 +296,4 @@ support-files = [ ["browser_warning_permanent_private_browsing.js"] ["browser_windows_launch_on_login.js"] -run-if = ["os == 'win'"] +run-if = ["(os == 'win' && !msix)"] # Disabled for MSIX due to https://bugzilla.mozilla.org/show_bug.cgi?id=1888263 diff --git a/browser/components/preferences/tests/browser_bug731866.js b/browser/components/preferences/tests/browser_bug731866.js index b090535a49..e9ecd08a81 100644 --- a/browser/components/preferences/tests/browser_bug731866.js +++ b/browser/components/preferences/tests/browser_bug731866.js @@ -7,6 +7,9 @@ const browserContainersGroupDisabled = !SpecialPowers.getBoolPref( const cookieBannerHandlingDisabled = !SpecialPowers.getBoolPref( "cookiebanners.ui.desktop.enabled" ); +const backupGroupDisabled = !SpecialPowers.getBoolPref( + "browser.backup.preferences.ui.enabled" +); const updatePrefContainers = ["updatesCategory", "updateApp"]; const updateContainersGroupDisabled = AppConstants.platform === "win" && @@ -60,6 +63,12 @@ function checkElements(expectedPane) { continue; } + // Backup is currently disabled by default. (bug 1895791) + if (element.id == "dataBackupGroup" && backupGroupDisabled) { + is_element_hidden(element, "Disabled dataBackupGroup should be hidden"); + continue; + } + let attributeValue = element.getAttribute("data-category"); let suffix = " (id=" + element.id + ")"; if (attributeValue == "pane" + expectedPane) { diff --git a/browser/components/preferences/tests/browser_connection_system_wpad.js b/browser/components/preferences/tests/browser_connection_system_wpad.js new file mode 100644 index 0000000000..87a3dbebae --- /dev/null +++ b/browser/components/preferences/tests/browser_connection_system_wpad.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_system_wpad() { + await openPreferencesViaOpenPreferencesAPI("general", { leaveOpen: true }); + const connectionURL = + "chrome://browser/content/preferences/dialogs/connection.xhtml"; + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("network.proxy.system_wpad.allowed"); + }); + + Services.prefs.setBoolPref("network.proxy.system_wpad.allowed", true); + let dialog = await openAndLoadSubDialog(connectionURL); + let dialogElement = dialog.document.getElementById("ConnectionsDialog"); + let systemWpad = dialog.document.getElementById("systemWpad"); + Assert.ok(!systemWpad.hidden, "Use system WPAD checkbox should be visible"); + let dialogClosingPromise = BrowserTestUtils.waitForEvent( + dialogElement, + "dialogclosing" + ); + dialogElement.cancelDialog(); + await dialogClosingPromise; + + Services.prefs.setBoolPref("network.proxy.system_wpad.allowed", false); + dialog = await openAndLoadSubDialog(connectionURL); + dialogElement = dialog.document.getElementById("ConnectionsDialog"); + systemWpad = dialog.document.getElementById("systemWpad"); + Assert.ok(systemWpad.hidden, "Use system WPAD checkbox should be hidden"); + dialogClosingPromise = BrowserTestUtils.waitForEvent( + dialogElement, + "dialogclosing" + ); + dialogElement.cancelDialog(); + await dialogClosingPromise; + + gBrowser.removeCurrentTab(); +}); diff --git a/browser/components/preferences/tests/browser_keyboardfocus.js b/browser/components/preferences/tests/browser_keyboardfocus.js index bed452b679..89576b926a 100644 --- a/browser/components/preferences/tests/browser_keyboardfocus.js +++ b/browser/components/preferences/tests/browser_keyboardfocus.js @@ -13,40 +13,42 @@ add_task(async function () { let checkbox = gBrowser.contentDocument.querySelector( "#useFullKeyboardNavigation" ); - Assert.ok( - !Services.prefs.getIntPref("accessibility.tabfocus", undefined), - "no pref value should exist" + Assert.equal( + Services.prefs.getIntPref("accessibility.tabfocus"), + 7, + "default should be full keyboard access" ); Assert.ok( - !checkbox.checked, - "checkbox should be unchecked before clicking on checkbox" + checkbox.checked, + "checkbox should be checked before clicking on checkbox" ); checkbox.click(); Assert.equal( Services.prefs.getIntPref("accessibility.tabfocus"), - 7, + 1, "Prefstore should reflect checkbox's associated numeric value" ); Assert.ok( - checkbox.checked, - "checkbox should be checked after clicking on checkbox" + !checkbox.checked, + "checkbox should be unchecked after clicking on checkbox" ); checkbox.click(); Assert.ok( - !checkbox.checked, - "checkbox should be unchecked after clicking on checkbox" + checkbox.checked, + "checkbox should be checked after clicking on checkbox" ); - Assert.ok( - !Services.prefs.getIntPref("accessibility.tabfocus", undefined), - "No pref value should exist" + Assert.equal( + Services.prefs.getIntPref("accessibility.tabfocus"), + 7, + "Should restore default value" ); BrowserTestUtils.removeTab(gBrowser.selectedTab); - SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 4]] }); + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 4]] }); await launchPreferences(); checkbox = gBrowser.contentDocument.querySelector( "#useFullKeyboardNavigation" @@ -57,20 +59,20 @@ add_task(async function () { "checkbox should stay unchecked after setting non-7 pref value" ); Assert.equal( - Services.prefs.getIntPref("accessibility.tabfocus", 0), + Services.prefs.getIntPref("accessibility.tabfocus"), 4, "pref should have value in store" ); BrowserTestUtils.removeTab(gBrowser.selectedTab); - SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); await launchPreferences(); checkbox = gBrowser.contentDocument.querySelector( "#useFullKeyboardNavigation" ); Assert.equal( - Services.prefs.getIntPref("accessibility.tabfocus", 0), + Services.prefs.getIntPref("accessibility.tabfocus"), 7, "Pref value should update after modification" ); diff --git a/browser/components/preferences/tests/browser_primaryPassword.js b/browser/components/preferences/tests/browser_primaryPassword.js index 1162ca1290..2c42bd8d91 100644 --- a/browser/components/preferences/tests/browser_primaryPassword.js +++ b/browser/components/preferences/tests/browser_primaryPassword.js @@ -30,6 +30,9 @@ add_task(async function () { isPrimaryPasswordSet() { return primaryPasswordSet; }, + getOSAuthEnabled() { + return true; // Since enabled by default. + }, }; let checkbox = doc.querySelector("#useMasterPassword"); diff --git a/browser/components/preferences/tests/browser_search_quickactions.js b/browser/components/preferences/tests/browser_search_quickactions.js index db938035ae..70799b5002 100644 --- a/browser/components/preferences/tests/browser_search_quickactions.js +++ b/browser/components/preferences/tests/browser_search_quickactions.js @@ -6,26 +6,26 @@ "use strict"; ChromeUtils.defineESModuleGetters(this, { - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", + ActionsProviderQuickActions: + "resource:///modules/ActionsProviderQuickActions.sys.mjs", UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", }); add_setup(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ - ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.secondaryActions.featureGate", true], ["browser.urlbar.quickactions.enabled", true], ], }); - UrlbarProviderQuickActions.addAction("testaction", { + ActionsProviderQuickActions.addAction("testaction", { commands: ["testaction"], label: "quickactions-downloads2", }); registerCleanupFunction(() => { - UrlbarProviderQuickActions.removeAction("testaction"); + ActionsProviderQuickActions.removeAction("testaction"); }); }); @@ -61,15 +61,23 @@ add_task(async function test_show_prefs() { await BrowserTestUtils.removeTab(tab); }); -async function testActionIsShown(window) { +async function testActionIsShown(window, name) { await UrlbarTestUtils.promiseAutocompleteResultPopup({ window, value: "testact", waitForFocus: SimpleTest.waitForFocus, }); try { - let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); - return result.providerName == "quickactions"; + await BrowserTestUtils.waitForMutationCondition( + window.document, + {}, + () => + !!window.document.querySelector( + `.urlbarView-action-btn[data-action=${name}]` + ) + ); + Assert.ok(true, `We found action "${name}"`); + return true; } catch (e) { return false; } @@ -100,7 +108,7 @@ add_task(async function test_prefs() { }); Assert.ok( - await testActionIsShown(window), + await testActionIsShown(window, "testaction"), "Actions are shown after user clicks checkbox" ); diff --git a/browser/components/privatebrowsing/ResetPBMPanel.sys.mjs b/browser/components/privatebrowsing/ResetPBMPanel.sys.mjs index f5e818c2a8..51bba1e6af 100644 --- a/browser/components/privatebrowsing/ResetPBMPanel.sys.mjs +++ b/browser/components/privatebrowsing/ResetPBMPanel.sys.mjs @@ -45,6 +45,9 @@ export const ResetPBMPanel = { onViewShowing(aEvent) { ResetPBMPanel.onViewShowing(aEvent); }, + onViewHiding(aEvent) { + ResetPBMPanel.onViewHiding(aEvent); + }, }; if (this._enabled) { @@ -59,7 +62,8 @@ export const ResetPBMPanel = { * the toolbar button. */ async onViewShowing(event) { - let triggeringWindow = event.target.ownerGlobal; + let panelview = event.target; + let triggeringWindow = panelview.ownerGlobal; // We may skip the confirmation panel if disabled via pref. if (!this._shouldConfirmClear) { @@ -68,7 +72,7 @@ export const ResetPBMPanel = { // If the action is triggered from the overflow menu make sure that the // panel gets hidden. - lazy.CustomizableUI.hidePanelForNode(event.target); + lazy.CustomizableUI.hidePanelForNode(panelview); // Trigger the restart action. await this._restartPBM(triggeringWindow); @@ -77,6 +81,8 @@ export const ResetPBMPanel = { return; } + panelview.addEventListener("command", this); + // Before the panel is shown, update checkbox state based on pref. this._rememberCheck(triggeringWindow).checked = this._shouldConfirmClear; @@ -86,6 +92,23 @@ export const ResetPBMPanel = { }); }, + onViewHiding(event) { + let panelview = event.target; + panelview.removeEventListener("command", this); + }, + + handleEvent(event) { + let button = event.target; + switch (button.id) { + case "reset-pbm-panel-cancel-button": + this.onCancel(button); + break; + case "reset-pbm-panel-confirm-button": + this.onConfirm(button); + break; + } + }, + /** * Handles the confirmation panel cancel button. * @param {MozButton} button - Cancel button that triggered the action. @@ -190,7 +213,7 @@ export const ResetPBMPanel = { }); // In the remaining PBM window: If the sidebar is open close it. - triggeringWindow.SidebarUI?.hide(); + triggeringWindow.SidebarController?.hide(); // Clear session store data for the remaining PBM window. lazy.SessionStore.purgeDataForPrivateWindow(triggeringWindow); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_pin_promo.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_pin_promo.js index bc62556b12..d34b93f4de 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_pin_promo.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_pin_promo.js @@ -54,57 +54,3 @@ add_task(async function test_pin_promo() { await BrowserTestUtils.closeWindow(win3); await BrowserTestUtils.closeWindow(win4); }); - -add_task(async function test_pin_promo_mr2022_holdback() { - ASRouter.resetMessageState(); - // Set majorRelease2022 feature onboarding variable fallback pref - // for inMr2022Holdback targeting to evaluate true - await SpecialPowers.pushPrefEnv({ - set: [["browser.majorrelease.onboarding", false]], - }); - await ASRouter.onPrefChange(); - let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); - - await SpecialPowers.spawn(tab1, [], async function () { - const promoContainer = content.document.querySelector(".promo"); - const promoButton = content.document.querySelector( - "#private-browsing-promo-link" - ); - - ok(promoContainer, "Promo is shown"); - - Assert.equal( - promoButton.getAttribute("data-l10n-id"), - "about-private-browsing-focus-promo-cta", - "Pin Promo not shown for holdback user" - ); - }); - - await BrowserTestUtils.closeWindow(win1); -}); - -add_task(async function test_pin_promo_mr2022_not_holdback() { - ASRouter.resetMessageState(); - // Set majorRelease2022 feature onboarding variable fallback pref - // for inMr2022Holdback targeting to evaluate false - await SpecialPowers.pushPrefEnv({ - set: [["browser.majorrelease.onboarding", true]], - }); - await ASRouter.onPrefChange(); - let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); - - await SpecialPowers.spawn(tab1, [], async function () { - const promoContainer = content.document.querySelector(".promo"); - const promoHeader = content.document.getElementById("promo-header"); - - ok(promoContainer, "Promo is shown"); - - is( - promoHeader.getAttribute("data-l10n-id"), - "about-private-browsing-pin-promo-header", - "Pin Promo is shown" - ); - }); - - await BrowserTestUtils.closeWindow(win1); -}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js index a80d818c87..f443b9bcd9 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js @@ -682,23 +682,23 @@ add_task(async function test_reset_action_closes_sidebar() { info( "Open the sidebar of both the private browsing window and the normal browsing window." ); - await SidebarUI.show("viewBookmarksSidebar"); - await win.SidebarUI.show("viewBookmarksSidebar"); + await SidebarController.show("viewBookmarksSidebar"); + await win.SidebarController.show("viewBookmarksSidebar"); info("Trigger the restart PBM action"); await ResetPBMPanel._restartPBM(win); Assert.ok( - SidebarUI.isOpen, + SidebarController.isOpen, "Normal browsing window sidebar should still be open." ); Assert.ok( - !win.SidebarUI.isOpen, + !win.SidebarController.isOpen, "Private browsing sidebar should be closed." ); // Cleanup: Close the sidebar of the normal browsing window. - SidebarUI.hide(); + SidebarController.hide(); // Cleanup: Close the private window that remained open. await BrowserTestUtils.closeWindow(win); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js index 58a333bfdb..9988f35969 100644 --- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js @@ -12,7 +12,7 @@ function test() { // opens a sidebar function openSidebar(win) { - return win.SidebarUI.show("viewBookmarksSidebar").then(() => win); + return win.SidebarController.show("viewBookmarksSidebar").then(() => win); } let windowCache = []; diff --git a/browser/components/protections/content/protections.html b/browser/components/protections/content/protections.html index 1374c30fd7..4f5b2d71a9 100644 --- a/browser/components/protections/content/protections.html +++ b/browser/components/protections/content/protections.html @@ -13,7 +13,6 @@ <meta name="color-scheme" content="light dark" /> <link rel="localization" href="branding/brand.ftl" /> <link rel="localization" href="browser/protections.ftl" /> - <link rel="localization" href="toolkit/branding/accounts.ftl" /> <link rel="localization" href="toolkit/branding/brandings.ftl" /> <!-- Temporary "en-US"-only l10n strings --> <link rel="localization" href="preview/protections.ftl" /> diff --git a/browser/components/resistfingerprinting/test/browser/browser.toml b/browser/components/resistfingerprinting/test/browser/browser.toml index 8fbd7b5b2f..dd60e383cb 100644 --- a/browser/components/resistfingerprinting/test/browser/browser.toml +++ b/browser/components/resistfingerprinting/test/browser/browser.toml @@ -10,6 +10,19 @@ support-files = [ "file_workerNetInfo.js", "file_workerPerformance.js", "head.js", + "file_canvascompare_aboutblank_iframee.html", + "file_canvascompare_aboutblank_iframer.html", + "file_canvascompare_aboutblank_popupmaker.html", + "file_canvascompare_blob_iframee.html", + "file_canvascompare_blob_iframer.html", + "file_canvascompare_blob_popupmaker.html", + "file_canvascompare_data_iframee.html", + "file_canvascompare_data_iframer.html", + "file_canvascompare_data_popupmaker.html", + "file_canvascompare_iframer.html", + "file_canvascompare_iframee.html", + "file_canvas_iframer.html", + "file_canvas_iframee.html", "file_navigator_header.sjs", "file_navigator_iframer.html", "file_navigator_iframee.html", @@ -41,17 +54,48 @@ support-files = [ ] ["browser_animationapi_iframes.js"] +lineno = "48" ["browser_block_mozAddonManager.js"] +lineno = "51" ["browser_bug1369357_site_specific_zoom_level.js"] https_first_disabled = true +lineno = "54" + +["browser_canvas_iframes.js"] +lineno = "58" + +["browser_canvas_popups.js"] +lineno = "61" + +["browser_canvascompare_iframes.js"] +lineno = "64" + +["browser_canvascompare_iframes_aboutblank.js"] + +["browser_canvascompare_iframes_blob.js"] + +["browser_canvascompare_iframes_data.js"] + +["browser_canvascompare_popups.js"] +lineno = "66" + +["browser_canvascompare_popups_aboutblank.js"] +lineno = "68" + +["browser_canvascompare_popups_blob.js"] + +["browser_canvascompare_popups_data.js"] ["browser_cross_origin_isolated_animation_api.js"] +lineno = "70" ["browser_cross_origin_isolated_performance_api.js"] +lineno = "73" ["browser_cross_origin_isolated_reduce_time_precision.js"] +lineno = "76" ["browser_dynamical_window_rounding.js"] https_first_disabled = true @@ -59,66 +103,96 @@ skip-if = [ "os == 'mac'", # Bug 1570812 "os == 'linux'", # Bug 1570812, Bug 1775698 ] +lineno = "79" ["browser_hwconcurrency_etp_iframes.js"] +lineno = "87" ["browser_hwconcurrency_iframes.js"] +lineno = "90" ["browser_hwconcurrency_iframes_aboutblank.js"] +lineno = "93" ["browser_hwconcurrency_iframes_aboutsrcdoc.js"] +lineno = "96" ["browser_hwconcurrency_iframes_blob.js"] +lineno = "99" ["browser_hwconcurrency_iframes_blobcrossorigin.js"] +lineno = "102" ["browser_hwconcurrency_iframes_data.js"] +lineno = "105" ["browser_hwconcurrency_iframes_sandboxediframe.js"] +lineno = "108" ["browser_hwconcurrency_popups.js"] +lineno = "111" ["browser_hwconcurrency_popups_aboutblank.js"] +lineno = "114" ["browser_hwconcurrency_popups_blob.js"] +lineno = "117" ["browser_hwconcurrency_popups_blob_noopener.js"] +lineno = "120" ["browser_hwconcurrency_popups_data.js"] +lineno = "123" ["browser_hwconcurrency_popups_data_noopener.js"] +lineno = "126" ["browser_hwconcurrency_popups_noopener.js"] +lineno = "129" ["browser_math.js"] +lineno = "132" ["browser_navigator.js"] https_first_disabled = true +lineno = "135" ["browser_navigator_iframes.js"] https_first_disabled = true +lineno = "139" ["browser_netInfo.js"] https_first_disabled = true +lineno = "143" ["browser_performanceAPI.js"] +lineno = "147" ["browser_performanceAPIWorkers.js"] +lineno = "150" ["browser_reduceTimePrecision_iframes.js"] https_first_disabled = true +lineno = "153" ["browser_roundedWindow_dialogWindow.js"] +lineno = "157" ["browser_roundedWindow_newWindow.js"] +lineno = "160" ["browser_roundedWindow_open_max_inner.js"] +lineno = "163" ["browser_roundedWindow_open_mid_inner.js"] +lineno = "166" ["browser_roundedWindow_open_min_inner.js"] +lineno = "169" ["browser_spoofing_keyboard_event.js"] skip-if = ["(debug || asan) && os == 'linux' && bits == 64"] #Bug 1518179 +lineno = "172" ["browser_timezone.js"] +lineno = "176" diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvas_iframes.js b/browser/components/resistfingerprinting/test/browser/browser_canvas_iframes.js new file mode 100644 index 0000000000..2b6497cc85 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvas_iframes.js @@ -0,0 +1,217 @@ +/** + * This tests that the canvas is correctly randomized on the iframe (not the framer) + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + * - (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain + * - (B) RFP is exempted on the framer and framee but is not on another (if needed) cross-origin domain + * - (C) RFP is exempted on the framer and (if needed) on another cross-origin domain, but not the framee + * - (D) RFP is exempted on the framer but not the framee nor another (if needed) cross-origin domain + * - (E) RFP is not exempted on the framer nor the framee but (if needed) is exempted on another cross-origin domain + * - (F) RFP is not exempted on the framer nor the framee nor another (if needed) cross-origin domain + * - (G) RFP is not exempted on the framer but is on the framee and (if needed) on another cross-origin domain + * - (H) RFP is not exempted on the framer nor another (if needed) cross-origin domain but is on the framee + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + let differences = countDifferencesInUint8Arrays( + result, + UNMODIFIED_CANVAS_DATA + ); + + Assert.greaterOrEqual( + differences, + expectedResults[0], + `Checking ${testDesc} for canvas randomization - did not see enough random pixels.` + ); + Assert.lessOrEqual( + differences, + expectedResults[1], + `Checking ${testDesc} for canvas randomization - saw too many random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(rfpFullyRandomized)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +const rfpFullyRandomized = [10000, 999999999]; +const fppRandomized = [1, 260]; +const noRandom = [0, 0]; + +// Note that the starting page and the iframe will be cross-domain from each other, but this test does not check that we inherit the randomizationkey, +// only that the iframe is randomized. +const uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvas_iframer.html?mode=iframe`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomized); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomized); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain +expectedResults = structuredClone(noRandom); +add_task(testA.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (B) RFP is exempted on the framer and framee but is not on another (if needed) cross-origin domain +expectedResults = structuredClone(noRandom); +add_task(testB.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (C) RFP is exempted on the framer and (if needed) on another cross-origin domain, but not the framee +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testC.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (D) RFP is exempted on the framer but not the framee nor another (if needed) cross-origin domain +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testD.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (E) RFP is not exempted on the framer nor the framee but (if needed) is exempted on another cross-origin domain +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testE.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (F) RFP is not exempted on the framer nor the framee nor another (if needed) cross-origin domain +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testF.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (G) RFP is not exempted on the framer but is on the framee and (if needed) on another cross-origin domain +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testG.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (H) RFP is not exempted on the framer nor another (if needed) cross-origin domain but is on the framee +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testH.bind(null, uri, testCanvasRandomization, expectedResults)); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvas_popups.js b/browser/components/resistfingerprinting/test/browser/browser_canvas_popups.js new file mode 100644 index 0000000000..234529b988 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvas_popups.js @@ -0,0 +1,198 @@ +/** + * This tests that the canvas is correctly randomized on a popup (not the starting page) + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + + * + * - (A) RFP is exempted on the maker and popup + * - (C) RFP is exempted on the maker but not the popup + * - (E) RFP is not exempted on the maker nor the popup + * - (G) RFP is not exempted on the maker but is on the popup + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + let differences = countDifferencesInUint8Arrays( + result, + UNMODIFIED_CANVAS_DATA + ); + + Assert.greaterOrEqual( + differences, + expectedResults[0], + `Checking ${testDesc} for canvas randomization - did not see enough random pixels.` + ); + Assert.lessOrEqual( + differences, + expectedResults[1], + `Checking ${testDesc} for canvas randomization - saw too many random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(rfpFullyRandomized)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +const rfpFullyRandomized = [10000, 999999999]; +const fppRandomized = [1, 260]; +const noRandom = [0, 0]; + +// Note that the starting page and the popup will be cross-domain from each other, but this test does not check that we inherit the randomizationkey, +// only that the popup is randomized. +const uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvas_iframer.html?mode=popup`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomized); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomized); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// (A) RFP is exempted on the opener and openee +expectedResults = structuredClone(noRandom); +add_task(testA.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (C) RFP is exempted on the opener but not the openee +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testC.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (E) RFP is not exempted on the opener nor the openee +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testE.bind(null, uri, testCanvasRandomization, expectedResults)); + +// (G) RFP is not exempted on the opener but is on the openee +expectedResults = structuredClone(rfpFullyRandomized); +add_task(testG.bind(null, uri, testCanvasRandomization, expectedResults)); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes.js new file mode 100644 index 0000000000..a4da3aa9ec --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes.js @@ -0,0 +1,263 @@ +/** + * This test compares canvas randomization on a parent and an iframe, and ensures that the canvas randomization key + * is inherited correctly. (e.g. that the canvases have the same random value) + * + * It runs all the tests twice - once for when the iframe is cross-domain, and once when it is same-domain + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomized = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +// Note that we are inheriting the randomization key ACROSS top-level domains that are cross-domain, because the iframe is a 3rd party domain +let uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html?mode=iframe`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomized); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomized); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// And here the we are inheriting the randomization key into an iframe that is same-domain to the parent +uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html?mode=iframe`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomized); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomized); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomized); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_aboutblank.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_aboutblank.js new file mode 100644 index 0000000000..1fd9ac2150 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_aboutblank.js @@ -0,0 +1,266 @@ +/** + * This test compares canvas randomization on an iframe and an iframe of about:blank within that iframe, and + * ensures that the canvas randomization key is inherited correctly. (e.g. that the canvases have the same + * random value.) There's three pages at play here: the parent frame, the iframe, and the about:blank iframe + * within the iframe. We only compare the inner-most two, we don't measure the outer one. + * + * It runs all the tests twice - once for when the iframe is cross-domain from the parent, and once when it is + * same-domain. But in both cases the about:blank iframe is same-domain to its parent. + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +// Note that we are inheriting the randomization key ACROSS top-level domains that are cross-domain, because the iframe is a 3rd party domain +let uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframer.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// And here the we are inheriting the randomization key into an iframe that is same-domain to the parent +uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframer.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_blob.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_blob.js new file mode 100644 index 0000000000..be7aedb174 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_blob.js @@ -0,0 +1,266 @@ +/** + * This test compares canvas randomization on an iframe and a blob: iframe within that iframe, and + * ensures that the canvas randomization key is inherited correctly. (e.g. that the canvases have the same + * random value.) There's three pages at play here: the parent frame, the iframe, and the blob iframe + * within the iframe. We only compare the inner-most two, we don't measure the outer one. + * + * It runs all the tests twice - once for when the iframe is cross-domain from the parent, and once when it is + * same-domain. But in both cases the blob iframe is same-domain to its parent. + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +// Note that we are inheriting the randomization key ACROSS top-level domains that are cross-domain, because the iframe is a 3rd party domain +let uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframer.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// And here the we are inheriting the randomization key into an iframe that is same-domain to the parent +uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframer.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_data.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_data.js new file mode 100644 index 0000000000..dcf10564e0 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_iframes_data.js @@ -0,0 +1,271 @@ +/** + * This test compares canvas randomization on an iframe and a data iframe within that iframe, and + * ensures that the canvas randomization key is inherited correctly. (e.g. that the canvases have the same + * random value.) There's three pages at play here: the parent frame, the iframe, and the data: iframe + * within the iframe. We only compare the inner-most two, we don't measure the outer one. + * + * It runs all the tests twice - once for when the iframe is cross-domain from the parent, and once when it is + * same-domain. But in both cases the data: iframe is same-domain to its parent. + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +// Note that we are inheriting the randomization key ACROSS top-level domains that are cross-domain, because the iframe is a 3rd party domain +let uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframer.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// And here the we are inheriting the randomization key into an iframe that is same-domain to the parent +uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframer.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups.js new file mode 100644 index 0000000000..b1b6f5e9d8 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups.js @@ -0,0 +1,268 @@ +/** + * This test compares canvas randomization on a parent and a popup, and ensures that the canvas randomization key + * is inherited correctly. (e.g. that the canvases have the same random value) + * + * It runs all the tests twice - once for when the popup is cross-domain, and once when it is same-domain + * We DO NOT inherit a randomization key across cross-domain popups, but we do for same-domain + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// The following are convenience objects that allow you to quickly see what is +// and is not modified from a logical set of values. +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const fppRandomizedCrossDomain = [1, 260, 2, 520]; +const noRandom = [0, 0, 0, 0]; + +// Note that we will be doing two sets of tests - one where the popup is on a cross-domain +// and one where it is on the same domain. First, we do same domain +let uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html?mode=popup`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Now, cross-domain. +uri = `https://${FRAMER_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html?mode=popup`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedCrossDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedCrossDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedCrossDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_aboutblank.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_aboutblank.js new file mode 100644 index 0000000000..5897aba7e5 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_aboutblank.js @@ -0,0 +1,269 @@ +/** + * This test compares canvas randomization on a parent and an about:blank popup, and ensures that the canvas randomization key + * is inherited correctly. (e.g. that the canvases have the same random value) + * + * It runs all the tests twice. Development showed that there were two different code paths that might get taken for + * about:blank popup creation depending on when in the page lifecycle the popup is created. I don't understand why + * that is, but at time of writing, the subtle difference in the test will hit both code paths. + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// The following are convenience objects that allow you to quickly see what is +// and is not modified from a logical set of values. +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +// As detailed in file_canvascompare_aboutblank_popupmaker.html - there is a difference in code +// paths for propagating information via LoadInfo when the popup is opened during onLoad vs +// later in the page. The two modes switch between these situations. +let uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_popupmaker.html?mode=addOnLoadCallback`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Technically mode=addOnLoadCallback adds the one relevant callback; so mode=<anything else> will omit the callback and result in the other scenario +uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_popupmaker.html?mode=skipOnLoadCallback`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_blob.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_blob.js new file mode 100644 index 0000000000..739aaf07b7 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_blob.js @@ -0,0 +1,208 @@ +/** + * This test compares canvas randomization on a parent and a blob popup, and ensures that the canvas randomization key + * is inherited correctly. (e.g. that the canvases have the same random value) + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +let uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_popupmaker.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_data.js b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_data.js new file mode 100644 index 0000000000..caca6e0ff9 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_canvascompare_popups_data.js @@ -0,0 +1,208 @@ +/** + * This test compares canvas randomization on a parent and a data popup, and ensures that the canvas randomization key + * is inherited correctly. (e.g. that the canvases have the same random value) + * + * Covers the following cases: + * - RFP/FPP is disabled entirely + * - RFP is enabled entirely, and only in PBM + * - FPP is enabled entirely, and only in PBM + * - A normal window when FPP is enabled globally and RFP is enabled in PBM, Protections Enabled and Disabled + * + */ + +"use strict"; + +// ============================================================================================= + +/** + * Compares two Uint8Arrays and returns the number of bits that are different. + * + * @param {Uint8ClampedArray} arr1 - The first Uint8ClampedArray to compare. + * @param {Uint8ClampedArray} arr2 - The second Uint8ClampedArray to compare. + * @returns {number} - The number of bits that are different between the two + * arrays. + */ +function countDifferencesInUint8Arrays(arr1, arr2) { + let count = 0; + for (let i = 0; i < arr1.length; i++) { + let diff = arr1[i] ^ arr2[i]; + while (diff > 0) { + count += diff & 1; + diff >>= 1; + } + } + return count; +} + +// ============================================================================================= + +async function testCanvasRandomization(result, expectedResults, extraData) { + let testDesc = extraData.testDesc; + + let parent = result.mine; + let child = result.theirs; + + let differencesInRandom = countDifferencesInUint8Arrays(parent, child); + let differencesFromUnmodifiedParent = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + parent + ); + let differencesFromUnmodifiedChild = countDifferencesInUint8Arrays( + UNMODIFIED_CANVAS_DATA, + child + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedParent, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing parent - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedParent, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing parent - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesFromUnmodifiedChild, + expectedResults[0], + `Checking ${testDesc} for canvas randomization, comparing child - lower bound for random pixels.` + ); + Assert.lessOrEqual( + differencesFromUnmodifiedChild, + expectedResults[1], + `Checking ${testDesc} for canvas randomization, comparing child - upper bound for random pixels.` + ); + + Assert.greaterOrEqual( + differencesInRandom, + expectedResults[2], + `Checking ${testDesc} and comparing randomization - lower bound for different random pixels.` + ); + Assert.lessOrEqual( + differencesInRandom, + expectedResults[3], + `Checking ${testDesc} and comparing randomization - upper bound for different random pixels.` + ); +} + +requestLongerTimeout(2); + +let expectedResults = {}; +var UNMODIFIED_CANVAS_DATA = undefined; + +add_setup(async function () { + // Disable the fingerprinting randomization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.fingerprintingProtection", false], + ["privacy.fingerprintingProtection.pbmode", false], + ["privacy.resistFingerprinting", false], + ], + }); + + let extractCanvasData = function () { + let offscreenCanvas = new OffscreenCanvas(100, 100); + + const context = offscreenCanvas.getContext("2d"); + + // Draw a red rectangle + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + const imageData = context.getImageData(0, 0, 100, 100); + return imageData.data; + }; + + function runExtractCanvasData(tab) { + let code = extractCanvasData.toString(); + return SpecialPowers.spawn(tab.linkedBrowser, [code], async funccode => { + await content.eval(`var extractCanvasData = ${funccode}`); + let result = await content.eval(`extractCanvasData()`); + return result; + }); + } + + const emptyPage = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + "empty.html"; + + // Open a tab for extracting the canvas data. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage); + + let data = await runExtractCanvasData(tab); + UNMODIFIED_CANVAS_DATA = data; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Be sure to always use `let expectedResults = structuredClone(allNotSpoofed)` to do a +// deep copy and avoiding corrupting the original 'const' object +// The first value represents the minimum number of random pixels we should see +// The second, the maximum number of random pixels +// The third, the minimum number of differences between the canvases of the parent and child +// The fourth, the maximum number of differences between the canvases of the parent and child +const rfpFullyRandomized = [10000, 999999999, 20000, 999999999]; +const fppRandomizedSameDomain = [1, 260, 0, 0]; +const noRandom = [0, 0, 0, 0]; + +let uri = `https://${IFRAME_DOMAIN}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_popupmaker.html`; + +expectedResults = structuredClone(noRandom); +add_task( + defaultsTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + defaultsPBMTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simpleRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(rfpFullyRandomized); +add_task( + simplePBMRFPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simpleFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + simplePBMFPPTest.bind(null, uri, testCanvasRandomization, expectedResults) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, No Protections +expectedResults = structuredClone(noRandom); +add_task( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); + +// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled +expectedResults = structuredClone(fppRandomizedSameDomain); +add_task( + RFPPBMFPP_NormalMode_ProtectionsTest.bind( + null, + uri, + testCanvasRandomization, + expectedResults + ) +); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_etp_iframes.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_etp_iframes.js index 47914e098e..7c66c511a5 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_etp_iframes.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_etp_iframes.js @@ -91,7 +91,7 @@ add_task( let extraPrefs = [ ["privacy.resistFingerprinting.pbmode", true], ["privacy.fingerprintingProtection", true], - ["privacy.fingerprintingProtection.overrides", "+HardwareConcurrency"], + ["privacy.fingerprintingProtection.overrides", "+NavigatorHWConcurrency"], ]; let this_extraData = structuredClone(extraData); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes.js index 1b89556f61..e9f7175909 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes.js @@ -5,7 +5,7 @@ * - RFP is disabled entirely * - RFP is enabled entirely * - FPP is enabled entirely - + * - RFP is enabled in PBM, FPP is enabled globally, testing in a Normal Window * * - (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain * - (B) RFP is exempted on the framer and framee but is not on another (if needed) cross-origin domain @@ -60,9 +60,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -95,8 +103,13 @@ add_task(testG.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); -// Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode +// Test a Normal Window with RFP Enabled in PBM and FPP enabled in Normal Browsing Mode - but FPP has no No Protections enabled in it (via .overrides pref) expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutblank.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutblank.js index 1c78cc997f..d48baf3b7d 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutblank.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutblank.js @@ -60,9 +60,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -98,5 +106,10 @@ add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutsrcdoc.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutsrcdoc.js index 6a650256a5..fcaba5b5c1 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutsrcdoc.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutsrcdoc.js @@ -60,9 +60,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -98,5 +106,10 @@ add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blob.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blob.js index adedb8b96b..9bafe0b43d 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blob.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blob.js @@ -60,9 +60,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -98,5 +106,10 @@ add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blobcrossorigin.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blobcrossorigin.js index 1bb7f268a9..be3a55cfb1 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blobcrossorigin.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blobcrossorigin.js @@ -63,9 +63,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain // In theory this should be Not Spoofed, however, in this test there is a blob: document that // has a content principal and a reference to the iframe's parent (when Fission is disabled anyway.) @@ -112,5 +120,10 @@ add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_data.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_data.js index 01690bce49..11d4e0ec87 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_data.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_data.js @@ -60,9 +60,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -98,5 +106,10 @@ add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_sandboxediframe.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_sandboxediframe.js index 05c2b33feb..f783937501 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_sandboxediframe.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_sandboxediframe.js @@ -60,9 +60,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the framer and framee and (if needed) on another cross-origin domain expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -98,5 +106,10 @@ add_task(testH.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups.js index a9e3591b62..5515da2a7a 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups.js @@ -56,9 +56,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the maker and popup expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -78,5 +86,10 @@ add_task(testG.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_aboutblank.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_aboutblank.js index 17f2960b62..7f99d52635 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_aboutblank.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_aboutblank.js @@ -55,9 +55,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the popup maker expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -69,5 +77,10 @@ add_task(testE.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob.js index b2f3ebf863..9487df372c 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob.js @@ -55,9 +55,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the popup maker expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -69,5 +77,10 @@ add_task(testE.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob_noopener.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob_noopener.js index 06e4166a4d..a4c5871a22 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob_noopener.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob_noopener.js @@ -65,11 +65,35 @@ add_task( simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults, extraData) ); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task( + simplePBMRFPTest.bind( + null, + uri, + testHWConcurrency, + expectedResults, + extraData + ) +); + expectedResults = structuredClone(allSpoofed); add_task( simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults, extraData) ); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task( + simplePBMFPPTest.bind( + null, + uri, + testHWConcurrency, + expectedResults, + extraData + ) +); + // (A) RFP is exempted on the popup maker // Ordinarily, RFP would be exempted, however because the opener relationship is severed // there is nothing to grant it an exemption, so it is not exempted. @@ -83,7 +107,7 @@ add_task(testE.bind(null, uri, testHWConcurrency, expectedResults, extraData)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( null, uri, testHWConcurrency, diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data.js index 7499c55303..1a9353bbf4 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data.js @@ -55,9 +55,17 @@ add_task(defaultsTest.bind(null, uri, testHWConcurrency, expectedResults)); expectedResults = structuredClone(allSpoofed); add_task(simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMRFPTest.bind(null, uri, testHWConcurrency, expectedResults)); + expectedResults = structuredClone(allSpoofed); add_task(simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task(simplePBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults)); + // (A) RFP is exempted on the popup maker expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults)); @@ -69,5 +77,10 @@ add_task(testE.bind(null, uri, testHWConcurrency, expectedResults)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind(null, uri, testHWConcurrency, expectedResults) + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( + null, + uri, + testHWConcurrency, + expectedResults + ) ); diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data_noopener.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data_noopener.js index 75f79ba10c..3d90fb61ff 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data_noopener.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data_noopener.js @@ -65,11 +65,35 @@ add_task( simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults, extraData) ); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task( + simplePBMRFPTest.bind( + null, + uri, + testHWConcurrency, + expectedResults, + extraData + ) +); + expectedResults = structuredClone(allSpoofed); add_task( simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults, extraData) ); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task( + simplePBMFPPTest.bind( + null, + uri, + testHWConcurrency, + expectedResults, + extraData + ) +); + // (A) RFP is exempted on the popup maker // Ordinarily, RFP would be exempted, however because the opener relationship is severed // there is nothing to grant it an exemption, so it is not exempted. @@ -83,7 +107,7 @@ add_task(testE.bind(null, uri, testHWConcurrency, expectedResults, extraData)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( null, uri, testHWConcurrency, diff --git a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_noopener.js b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_noopener.js index 96125c5e20..b9160eb245 100644 --- a/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_noopener.js +++ b/browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_noopener.js @@ -65,11 +65,35 @@ add_task( simpleRFPTest.bind(null, uri, testHWConcurrency, expectedResults, extraData) ); +// Test a private window with RFP enabled in PBMode +expectedResults = structuredClone(allSpoofed); +add_task( + simplePBMRFPTest.bind( + null, + uri, + testHWConcurrency, + expectedResults, + extraData + ) +); + expectedResults = structuredClone(allSpoofed); add_task( simpleFPPTest.bind(null, uri, testHWConcurrency, expectedResults, extraData) ); +// Test a Private Window with FPP Enabled in PBM +expectedResults = structuredClone(allSpoofed); +add_task( + simplePBMFPPTest.bind( + null, + uri, + testHWConcurrency, + expectedResults, + extraData + ) +); + // (A) RFP is exempted on the maker and popup expectedResults = structuredClone(allNotSpoofed); add_task(testA.bind(null, uri, testHWConcurrency, expectedResults, extraData)); @@ -91,7 +115,7 @@ add_task(testG.bind(null, uri, testHWConcurrency, expectedResults, extraData)); // Test RFP Enabled in PBM and FPP enabled in Normal Browsing Mode expectedResults = structuredClone(allNotSpoofed); add_task( - simpleRFPPBMFPPTest.bind( + RFPPBMFPP_NormalMode_NoProtectionsTest.bind( null, uri, testHWConcurrency, diff --git a/browser/components/resistfingerprinting/test/browser/file_canvas_iframee.html b/browser/components/resistfingerprinting/test/browser/file_canvas_iframee.html new file mode 100644 index 0000000000..811eb7ee46 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvas_iframee.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script> +var parent_window; +let params = new URLSearchParams(document.location.search); +if (params.get("mode") == "popup") { + parent_window = window.opener; +} else { + parent_window = window.parent; +} + +window.onload = async () => { + parent_window.postMessage("ready", "*"); +} + +window.addEventListener("message", async function listener(event) { + if (event.data[0] == "gimme") { + let result = give_result(); + parent_window.postMessage(result, "*") + } +}); + +function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; +} +</script> +<output id="result"></output> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvas_iframer.html b/browser/components/resistfingerprinting/test/browser/file_canvas_iframer.html new file mode 100644 index 0000000000..3c9f2b65a7 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvas_iframer.html @@ -0,0 +1,55 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title></title> +<script src="shared_test_funcs.js"></script> +<script> +async function runTheTest(iframe_domain, cross_origin_domain) { + var child_reference; + let url = `https://${iframe_domain}/browser/browser/components/resistfingerprinting/test/browser/file_canvas_iframee.html?mode=` + let params = new URLSearchParams(document.location.search); + + if (params.get("mode") == 'iframe') { + const iframes = document.querySelectorAll("iframe"); + iframes[0].src = url + 'iframe'; + child_reference = iframes[0].contentWindow; + } else if (params.get("mode") == "popup") { + let options = ""; + if (params.get("submode") == "noopener") { + options = "noopener"; + } + const popup = window.open(url + 'popup', '', options); + if (params.get("submode") == "noopener") { + return {}; + } + child_reference = popup; + } else { + throw new Error("Unknown page mode specified"); + } + + await waitForMessage("ready", `https://${iframe_domain}`); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + if(event.origin != `https://${iframe_domain}`) { + throw new Error(`origin should be ${iframe_domain}`); + } + resolve(event.data); + }, { once: true }); + }); + child_reference.postMessage(["gimme", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + if (params.get("mode") == "popup") { + child_reference.close(); + } + + return result; +} +</script> +</head> +<body> +<iframe width=100></iframe> +</body> +</html> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframee.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframee.html new file mode 100644 index 0000000000..c123ecc3e9 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframee.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<body> +<output id="result"></output> +<script> +window.onload = async () => { + parent.postMessage("ready", "*"); +} + +window.addEventListener("message", async function listener(event) { + if (event.data[0] == "gimme") { + var iframe = document.createElement("iframe"); + iframe.src = "about:blank?foo"; + document.body.append(iframe); + + function test() { + function give_inner_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + window.parent.document.querySelector("#result").textContent = JSON.stringify(give_inner_result()); + } + + iframe.contentWindow.eval(`(${test})()`); + + + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + let myResult = give_result(); + + parent.postMessage({mine: myResult, theirs: JSON.parse(document.querySelector("#result").textContent)}, "*") + + // Fun fact - without clearing the text content of the element, the test will hang on shutdown + // Guess how many hours it took to figure _that_ out? + document.querySelector("#result").textContent = ''; + } +}); +</script> +</body> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframer.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframer.html new file mode 100644 index 0000000000..71a27e6098 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_iframer.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title></title> +<script src="shared_test_funcs.js"></script> +<script> +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_canvascompare_aboutblank_iframee.html`; + await waitForMessage("ready", `https://${iframe_domain}`); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + if(event.origin != `https://${iframe_domain}`) { + throw new Error(`origin should be ${iframe_domain}`); + } + resolve(event.data); + }, { once: true }); + }); + iframes[0].contentWindow.postMessage(["gimme", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + return result; +} +</script> +</head> +<body> +<iframe width=100></iframe> +</body> +</html> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_popupmaker.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_popupmaker.html new file mode 100644 index 0000000000..74e54ffdf2 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_aboutblank_popupmaker.html @@ -0,0 +1,110 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script src="shared_test_funcs.js"></script> +<script> +var popup = undefined; +function createPopup() { + if(popup === undefined) { + let s = ` + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + + window.addEventListener('message', async function listener(event) { + if (event.data[0] == 'popup_is_ready') { + window.opener.postMessage(["popup_ready"], "*"); + } else if (event.data[0] == 'popup_request') { + window.opener.postMessage(['popup_response', give_result()], '*'); + window.close(); + } + }); + setInterval(function() { + if(!window.opener || window.opener.closed) { + window.close(); + } + }, 50);`; + + popup = window.open("about:blank", ""); + popup.eval(s); + } +} + +/* + * Believe it or not, when the popup is created alters the code paths for + * how the RandomKey is populated on the CJS of the popup. It's a pretty + * drastic change, and the two changes in the substative (non-test) patch + * of this bug are the two different locations. I'll also note that it took + * probably 20 hours or more of work to figure out the LoadInfo ctor one, + * so I want to have test coverage of both paths, even if I don't understand + * _why_ there are two paths. + */ +let params = new URLSearchParams(document.location.search); +if (params.get("mode") == 'addOnLoadCallback') { + window.addEventListener("load", createPopup); +} + +async function runTheTest(iframe_domain, cross_origin_domain) { + await new Promise(r => setTimeout(r, 2000)); + + if (document.readyState !== 'complete') { + createPopup(); + } else if(popup === undefined) { + createPopup(); + } + + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + let myResult = give_result(); + + popup.postMessage(["popup_is_ready", cross_origin_domain], "*"); + await waitForMessage("popup_ready", `*`); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + resolve({mine: myResult, theirs: event.data[1]}); + }, { once: true }); + }); + + popup.postMessage(["popup_request", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + popup.close(); + + return result; +} + +</script> +<output id="result"></output> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframee.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframee.html new file mode 100644 index 0000000000..84e785777e --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframee.html @@ -0,0 +1,75 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script type="text/javascript"> +window.onload = async () => { + parent.postMessage("ready", "*"); +} + +function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; +} + +window.addEventListener("message", async function listener(event) { +//window.addEventListener("load", async function listener(event) { + if (event.data[0] == "gimme") { + // eslint-disable-next-line + var s = `<html><body><script> + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + window.parent.document.querySelector('#result').textContent = JSON.stringify(give_result()); + window.parent.postMessage(["frame_response"], "*");`; + // eslint-disable-next-line + s += `</` + `script></body></html>`; + + let b = new Blob([s], { type: "text/html" }); + let url = URL.createObjectURL(b); + + var iframe = document.createElement("iframe"); + iframe.src = url; + document.body.append(iframe); + } else if (event.data[0] == "frame_response") { + let myResult = give_result(); + console.log("myResult", myResult) + + let result = JSON.parse(document.querySelector("#result").textContent); + console.log("theirResult", result) + parent.postMessage({mine: myResult, theirs: result}, "*") + } +}); +</script> +<body> +<output id="result" style="display:none"></output> +</body> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframer.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframer.html new file mode 100644 index 0000000000..ac64101600 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_iframer.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title></title> +<script src="shared_test_funcs.js"></script> +<script> +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_canvascompare_blob_iframee.html`; + await waitForMessage("ready", `https://${iframe_domain}`); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + if(event.origin != `https://${iframe_domain}`) { + throw new Error(`origin should be ${iframe_domain}`); + } + resolve(event.data); + }, { once: true }); + }); + iframes[0].contentWindow.postMessage(["gimme", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + return result; +} +</script> +</head> +<body> +<iframe width=100></iframe> +</body> +</html> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_popupmaker.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_popupmaker.html new file mode 100644 index 0000000000..454ecb0a7f --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_blob_popupmaker.html @@ -0,0 +1,92 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script src="shared_test_funcs.js"></script> +<script type="text/javascript"> +var popup; +async function runTheTest(iframe_domain, cross_origin_domain) { + let s = `<html><script> + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + window.addEventListener('load', async function listener(event) { + window.opener.postMessage(["popup_ready"], "*"); + }); + window.addEventListener('message', async function listener(event) { + if (event.data[0] == 'popup_request') { + window.opener.postMessage(['popup_response', give_result()], '*'); + window.close(); + } + });`; + // eslint-disable-next-line + s += `</` + `script></html>`; + + let params = new URLSearchParams(document.location.search); + let options = ""; + if (params.get("submode") == "noopener") { + options = "noopener"; + } + + let b = new Blob([s], { type: "text/html" }); + let url = URL.createObjectURL(b); + popup = window.open(url, "", options); + + if (params.get("submode") == "noopener") { + return {}; + } + + await waitForMessage("popup_ready", `*`); + + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + let myResult = give_result(); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + resolve({mine: myResult, theirs: event.data[1]}); + }, { once: true }); + }); + + popup.postMessage(["popup_request", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + popup.close(); + + return result; +} +</script> +<body> +<output id="result"></output> +</body> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframee.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframee.html new file mode 100644 index 0000000000..856dc8b33d --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframee.html @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script type="text/javascript"> +window.onload = async () => { + parent.postMessage("ready", "*"); +} + +window.addEventListener("message", async function listener(event) { + if (event.data[0] == "gimme") { + var s = `<html><script> + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + window.addEventListener("load", async function listener(event) { + parent.postMessage(["frame_ready"], "*"); + }); + window.addEventListener('message', async function listener(event) { + if (event.data[0] == 'frame_request') { + + parent.postMessage(['frame_response', give_result()], '*'); + } + });`; + // eslint-disable-next-line + s += `</` + `script></html>`; + + let iframe = document.createElement("iframe"); + iframe.src = "data:text/html;base64," + btoa(s); + document.body.append(iframe); + } else if (event.data[0] == "frame_ready") { + let iframe = document.getElementsByTagName("iframe")[0]; + iframe.contentWindow.postMessage(["frame_request"], "*"); + } else if (event.data[0] == "frame_response") { + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + let myResult = give_result(); + + parent.postMessage({mine: myResult, theirs: event.data[1]}, "*") + } +}); +</script> +<body> +<output id="result"></output> +</body> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframer.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframer.html new file mode 100644 index 0000000000..c62e5367cb --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_iframer.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title></title> +<script src="shared_test_funcs.js"></script> +<script> +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_canvascompare_data_iframee.html`; + await waitForMessage("ready", `https://${iframe_domain}`); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + if(event.origin != `https://${iframe_domain}`) { + throw new Error(`origin should be ${iframe_domain}`); + } + resolve(event.data); + }, { once: true }); + }); + iframes[0].contentWindow.postMessage(["gimme", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + return result; +} +</script> +</head> +<body> +<iframe width=100></iframe> +</body> +</html> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_popupmaker.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_popupmaker.html new file mode 100644 index 0000000000..b9cefeb197 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_data_popupmaker.html @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script src="shared_test_funcs.js"></script> +<script type="text/javascript"> +var popup; +async function runTheTest(iframe_domain, cross_origin_domain) { + let s = `<!DOCTYPE html><html><script> + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + window.addEventListener('load', async function listener(event) { + window.opener.postMessage(["popup_ready"], "*"); + }); + window.addEventListener('message', async function listener(event) { + if (event.data[0] == 'popup_request') { + window.opener.postMessage(['popup_response', give_result()], '*'); + window.close(); + } + });`; + // eslint-disable-next-line + s += `</` + `script></html>`; + + let params = new URLSearchParams(document.location.search); + let options = ""; + if (params.get("submode") == "noopener") { + options = "noopener"; + } + + let url = "data:text/html;base64," + btoa(s); + popup = window.open(url, "", options); + + if (params.get("submode") == "noopener") { + return {}; + } + + await waitForMessage("popup_ready", `*`); + + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + let myResult = give_result(); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + resolve({mine: myResult, theirs: event.data[1]}); + }, { once: true }); + }); + + popup.postMessage(["popup_request", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + popup.close(); + + return result; +} +</script> +<body> +<output id="result"></output> +</body> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframee.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframee.html new file mode 100644 index 0000000000..811eb7ee46 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframee.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<script> +var parent_window; +let params = new URLSearchParams(document.location.search); +if (params.get("mode") == "popup") { + parent_window = window.opener; +} else { + parent_window = window.parent; +} + +window.onload = async () => { + parent_window.postMessage("ready", "*"); +} + +window.addEventListener("message", async function listener(event) { + if (event.data[0] == "gimme") { + let result = give_result(); + parent_window.postMessage(result, "*") + } +}); + +function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; +} +</script> +<output id="result"></output> diff --git a/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html b/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html new file mode 100644 index 0000000000..e164ad21be --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframer.html @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title></title> +<script src="shared_test_funcs.js"></script> +<script> +async function runTheTest(iframe_domain, cross_origin_domain) { + var child_reference; + let url = `https://${iframe_domain}/browser/browser/components/resistfingerprinting/test/browser/file_canvascompare_iframee.html?mode=` + let params = new URLSearchParams(document.location.search); + + if (params.get("mode") == 'iframe') { + const iframes = document.querySelectorAll("iframe"); + iframes[0].src = url + 'iframe'; + child_reference = iframes[0].contentWindow; + } else if (params.get("mode") == "popup") { + let options = ""; + if (params.get("submode") == "noopener") { + options = "noopener"; + } + const popup = window.open(url + 'popup', '', options); + if (params.get("submode") == "noopener") { + return {}; + } + child_reference = popup; + } else { + throw new Error("Unknown page mode specified"); + } + + function give_result() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const context = canvas.getContext("2d"); + + context.fillStyle = "#EE2222"; + context.fillRect(0, 0, 100, 100); + context.fillStyle = "#2222EE"; + context.fillRect(20, 20, 100, 100); + + // Add the canvas element to the document + document.body.appendChild(canvas); + + const imageData = context.getImageData(0, 0, 100, 100); + + return imageData.data; + } + let myResult = give_result(); + + await waitForMessage("ready", `https://${iframe_domain}`); + + const promiseForRFPTest = new Promise(resolve => { + window.addEventListener("message", event => { + if(event.origin != `https://${iframe_domain}`) { + throw new Error(`origin should be ${iframe_domain}`); + } + + resolve({mine: myResult, theirs: event.data}); + }, { once: true }); + }); + child_reference.postMessage(["gimme", cross_origin_domain], "*"); + var result = await promiseForRFPTest; + + if (params.get("mode") == "popup") { + child_reference.close(); + } + + return result; +} +</script> +</head> +<body> +<iframe width=100></iframe> +</body> +</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 75ae15313b..26e9656398 100644 --- a/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html +++ b/browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html @@ -14,9 +14,7 @@ async function runTheTest(iframe_domain, cross_origin_domain) { window.opener.postMessage(["popup_ready"], "*"); }); window.addEventListener('message', async function listener(event) { - if (event.data[0] == 'popup_is_ready') { - window.opener.postMessage(["popup_ready"], "*"); - } else if (event.data[0] == 'popup_request') { + if (event.data[0] == 'popup_request') { let result = give_result(); window.opener.postMessage(['popup_response', result], '*'); } diff --git a/browser/components/resistfingerprinting/test/browser/head.js b/browser/components/resistfingerprinting/test/browser/head.js index 8973839220..18a96994c7 100644 --- a/browser/components/resistfingerprinting/test/browser/head.js +++ b/browser/components/resistfingerprinting/test/browser/head.js @@ -753,6 +753,30 @@ async function defaultsTest( } } +async function defaultsPBMTest( + uri, + testFunction, + expectedResults, + extraData, + extraPrefs +) { + if (extraData == undefined) { + extraData = {}; + } + extraData.private_window = true; + extraData.testDesc = extraData.testDesc || "default PBM window"; + expectedResults.shouldRFPApply = false; + if (extraPrefs != undefined) { + await SpecialPowers.pushPrefEnv({ + set: extraPrefs, + }); + } + await runActualTest(uri, testFunction, expectedResults, extraData); + if (extraPrefs != undefined) { + await SpecialPowers.popPrefEnv(); + } +} + async function simpleRFPTest( uri, testFunction, @@ -813,7 +837,10 @@ async function simpleFPPTest( await SpecialPowers.pushPrefEnv({ set: [ ["privacy.fingerprintingProtection", true], - ["privacy.fingerprintingProtection.overrides", "+NavigatorHWConcurrency"], + [ + "privacy.fingerprintingProtection.overrides", + "+NavigatorHWConcurrency,+CanvasRandomization", + ], ].concat(extraPrefs || []), }); @@ -838,7 +865,42 @@ async function simplePBMFPPTest( await SpecialPowers.pushPrefEnv({ set: [ ["privacy.fingerprintingProtection.pbmode", true], - ["privacy.fingerprintingProtection.overrides", "+HardwareConcurrency"], + [ + "privacy.fingerprintingProtection.overrides", + "+NavigatorHWConcurrency,+CanvasRandomization", + ], + ].concat(extraPrefs || []), + }); + + await runActualTest(uri, testFunction, expectedResults, extraData); + + await SpecialPowers.popPrefEnv(); +} + +async function RFPPBMFPP_NormalMode_NoProtectionsTest( + uri, + testFunction, + expectedResults, + extraData, + extraPrefs +) { + if (extraData == undefined) { + extraData = {}; + } + extraData.private_window = false; + extraData.testDesc = + extraData.testDesc || + "RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Disabled"; + expectedResults.shouldRFPApply = false; + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.resistFingerprinting", false], + ["privacy.resistFingerprinting.pbmode", true], + ["privacy.fingerprintingProtection", true], + [ + "privacy.fingerprintingProtection.overrides", + "-NavigatorHWConcurrency,-CanvasRandomization", + ], ].concat(extraPrefs || []), }); @@ -847,7 +909,7 @@ async function simplePBMFPPTest( await SpecialPowers.popPrefEnv(); } -async function simpleRFPPBMFPPTest( +async function RFPPBMFPP_NormalMode_ProtectionsTest( uri, testFunction, expectedResults, @@ -860,14 +922,17 @@ async function simpleRFPPBMFPPTest( extraData.private_window = false; extraData.testDesc = extraData.testDesc || - "RFP Enabled in PBM and FPP enabled in Normal Browsing Mode"; + "RFP Enabled in PBM and FPP enabled in Normal Browsing Mode, Protections Enabled"; expectedResults.shouldRFPApply = false; await SpecialPowers.pushPrefEnv({ set: [ ["privacy.resistFingerprinting", false], ["privacy.resistFingerprinting.pbmode", true], ["privacy.fingerprintingProtection", true], - ["privacy.fingerprintingProtection.overrides", "-HardwareConcurrency"], + [ + "privacy.fingerprintingProtection.overrides", + "+NavigatorHWConcurrency,+CanvasRandomization", + ], ].concat(extraPrefs || []), }); diff --git a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs index 3718b6a4e0..aa9dbfdbd3 100644 --- a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs +++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs @@ -88,22 +88,18 @@ export class ScreenshotsOverlay { 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", + id: "screenshots-component-download-button-2", args: { shortcut: downloadShortcut }, }, - { id: "screenshots-component-copy-button-label" }, { - id: "screenshots-component-copy-button", + id: "screenshots-component-copy-button-2", args: { shortcut: copyShorcut }, }, ]); @@ -137,31 +133,31 @@ export class ScreenshotsOverlay { <div id="mover-topRight" class="mover-target direction-topRight" tabindex="0"> <div class="mover"></div> </div> - <div id="mover-left" class="mover-target direction-left"> - <div class="mover"></div> - </div> <div id="mover-right" class="mover-target direction-right"> <div class="mover"></div> </div> - <div id="mover-bottomLeft" class="mover-target direction-bottomLeft" tabindex="0"> + <div id="mover-bottomRight" class="mover-target direction-bottomRight" tabindex="0"> <div class="mover"></div> </div> <div id="mover-bottom" class="mover-target direction-bottom"> <div class="mover"></div> </div> - <div id="mover-bottomRight" class="mover-target direction-bottomRight" tabindex="0"> + <div id="mover-bottomLeft" class="mover-target direction-bottomLeft" tabindex="0"> + <div class="mover"></div> + </div> + <div id="mover-left" class="mover-target direction-left"> <div class="mover"></div> </div> <div id="selection-size-container"> - <span id="selection-size"></span> + <span id="selection-size" dir="ltr"></span> </div> </div> </div> <div id="buttons-container" hidden> <div class="buttons-wrapper"> <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> + <button id="copy" class="screenshots-button" title="${copyAttributes.attributes[0].value}" aria-label="${copyAttributes.attributes[1].value}"><img/><label>${copyAttributes.value}</label></button> + <button id="download" class="screenshots-button primary" title="${downloadAttributes.attributes[0].value}" aria-label="${downloadAttributes.attributes[1].value}"><img/><label>${downloadAttributes.value}</label></button> </div> </div> </div> @@ -240,6 +236,12 @@ export class ScreenshotsOverlay { this.#setState(STATES.CROSSHAIRS); + this.selection = this.window.getSelection(); + this.ranges = []; + for (let i = 0; i < this.selection.rangeCount; i++) { + this.ranges.push(this.selection.getRangeAt(i)); + } + this.#initialized = true; } @@ -303,6 +305,10 @@ export class ScreenshotsOverlay { }; } + focus() { + this.previewCancelButton.focus({ focusVisible: true }); + } + /** * Returns the x and y coordinates of the event relative to both the * viewport and the page. @@ -316,7 +322,9 @@ export class ScreenshotsOverlay { * } */ getCoordinatesFromEvent(event) { - const { clientX, clientY, pageX, pageY } = event; + let { clientX, clientY, pageX, pageY } = event; + pageX -= this.windowDimensions.scrollMinX; + pageY -= this.windowDimensions.scrollMinY; return { clientX, clientY, pageX, pageY }; } @@ -341,6 +349,9 @@ export class ScreenshotsOverlay { case "keyup": this.handleKeyUp(event); break; + case "selectionchange": + this.handleSelectionChange(); + break; } } @@ -498,6 +509,127 @@ export class ScreenshotsOverlay { * @param {Event} event The keydown event */ handleKeyDown(event) { + if (event.key === "Escape") { + this.maybeCancelScreenshots(); + return; + } + + switch (this.#state) { + case STATES.CROSSHAIRS: + this.crosshairsKeyDown(event); + break; + case STATES.DRAGGING: + this.draggingKeyDown(event); + break; + case STATES.RESIZING: + this.resizingKeyDown(event); + break; + case STATES.SELECTED: + this.selectedKeyDown(event); + break; + } + } + + /** + * Handles when a keyup occurs in the screenshots component. + * All we need to do on keyup is set the state to selected. + * @param {Event} event The keydown event + */ + handleKeyUp(event) { + switch (this.#state) { + case STATES.RESIZING: + switch (event.key) { + case "ArrowLeft": + case "ArrowUp": + case "ArrowRight": + case "ArrowDown": + switch (event.originalTarget.id) { + case "highlight": + case "mover-bottomLeft": + case "mover-bottomRight": + case "mover-topLeft": + case "mover-topRight": + event.preventDefault(); + this.#setState(STATES.SELECTED, { doNotMoveFocus: true }); + break; + } + break; + } + break; + } + } + + /** + * Gets the accel key depending on the platform. + * metaKey for macOS. ctrlKey for Windows and Linux. + * @param {Event} event The keydown event + * @returns {Boolean} True if the accel key is pressed, false otherwise. + */ + getAccelKey(event) { + if (AppConstants.platform === "macosx") { + return event.metaKey; + } + return event.ctrlKey; + } + + crosshairsKeyDown(event) { + switch (event.key) { + case "ArrowLeft": + case "ArrowUp": + case "ArrowRight": + case "ArrowDown": + // Do nothing so we can prevent default below + break; + case "Tab": + this.maybeLockFocus(event); + return; + case "Enter": + if (this.hoverElementRegion.isRegionValid) { + event.preventDefault(); + this.draggingReadyStart(); + this.draggingReadyDragEnd(); + return; + } + // eslint-disable-next-line no-fallthrough + case " ": { + if (Services.appinfo.isWayland) { + return; + } + + if (event.originalTarget === this.previewCancelButton) { + return; + } + + event.preventDefault(); + // The left and top coordinates from cursorRegion are relative to + // the client window so we need to add the scroll offset of the page to + // get the correct coordinates. + let x = {}; + let y = {}; + this.window.windowUtils.getLastOverWindowPointerLocationInCSSPixels( + x, + y + ); + this.crosshairsDragStart( + x.value + this.windowDimensions.scrollX, + y.value + this.windowDimensions.scrollY + ); + this.#setState(STATES.DRAGGING); + break; + } + default: + return; + } + + // Prevent scrolling with arrow keys + event.preventDefault(); + } + + /** + * Handles a keydown event for the dragging state. + * @param {Event} event The keydown event + */ + draggingKeyDown(event) { switch (event.key) { case "ArrowLeft": this.handleArrowLeftKeyDown(event); @@ -511,11 +643,71 @@ export class ScreenshotsOverlay { case "ArrowDown": this.handleArrowDownKeyDown(event); break; + case "Enter": + case " ": + event.preventDefault(); + this.#setState(STATES.SELECTED); + return; + default: + return; + } + + this.drawSelectionContainer(); + } + + /** + * Handles a keydown event for the resizing state. + * @param {Event} event The keydown event + */ + resizingKeyDown(event) { + switch (event.key) { + case "ArrowLeft": + this.resizingArrowLeftKeyDown(event); + break; + case "ArrowUp": + this.resizingArrowUpKeyDown(event); + break; + case "ArrowRight": + this.resizingArrowRightKeyDown(event); + break; + case "ArrowDown": + this.resizingArrowDownKeyDown(event); + break; + } + } + + selectedKeyDown(event) { + let isSelectionElement = event.originalTarget.closest( + "#selection-container" + ); + switch (event.key) { + case "ArrowLeft": + if (isSelectionElement) { + this.resizingArrowLeftKeyDown(event); + } + break; + case "ArrowUp": + if (isSelectionElement) { + this.resizingArrowUpKeyDown(event); + } + break; + case "ArrowRight": + if (isSelectionElement) { + this.resizingArrowRightKeyDown(event); + } + break; + case "ArrowDown": + if (isSelectionElement) { + this.resizingArrowDownKeyDown(event); + } + break; case "Tab": this.maybeLockFocus(event); break; - case "Escape": - this.maybeCancelScreenshots(); + case " ": + if (!event.originalTarget.closest("#buttons-container")) { + event.preventDefault(); + } break; case this.copyKey.toLowerCase(): if (this.state === "selected" && this.getAccelKey(event)) { @@ -533,16 +725,20 @@ export class ScreenshotsOverlay { } /** - * Gets the accel key depending on the platform. - * metaKey for macOS. ctrlKey for Windows and Linux. + * Move the region or its left or right side to the left. + * Just the arrow key will move the region by 1px. + * Arrow key + shift will move the region by 10px. + * Arrow key + control/meta will move to the edge of the window. * @param {Event} event The keydown event - * @returns {Boolean} True if the accel key is pressed, false otherwise. */ - getAccelKey(event) { - if (AppConstants.platform === "macosx") { - return event.metaKey; + resizingArrowLeftKeyDown(event) { + this.handleArrowLeftKeyDown(event); + + if (this.#state !== STATES.RESIZING) { + this.#setState(STATES.RESIZING); } - return event.ctrlKey; + + this.drawSelectionContainer(); } /** @@ -553,6 +749,7 @@ export class ScreenshotsOverlay { * @param {Event} event The keydown event */ handleArrowLeftKeyDown(event) { + let exponent = event.shiftKey ? 1 : 0; switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { @@ -562,7 +759,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.right -= 10 ** event.shiftKey; + this.selectionRegion.right -= 10 ** exponent; // eslint-disable-next-line no-fallthrough case "mover-topLeft": case "mover-bottomLeft": @@ -571,7 +768,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.left -= 10 ** event.shiftKey; + this.selectionRegion.left -= 10 ** exponent; this.scrollIfByEdge( this.selectionRegion.left, this.windowDimensions.scrollY + this.windowDimensions.clientHeight / 2 @@ -591,7 +788,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.right -= 10 ** event.shiftKey; + this.selectionRegion.right -= 10 ** exponent; if (this.selectionRegion.x1 >= this.selectionRegion.x2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-topRight") { @@ -605,11 +802,23 @@ export class ScreenshotsOverlay { return; } + event.preventDefault(); + } + + /** + * Move the region or its top or bottom side upward. + * Just the arrow key will move the region by 1px. + * Arrow key + shift will move the region by 10px. + * Arrow key + control/meta will move to the edge of the window. + * @param {Event} event The keydown event + */ + resizingArrowUpKeyDown(event) { + this.handleArrowUpKeyDown(event); + if (this.#state !== STATES.RESIZING) { this.#setState(STATES.RESIZING); } - event.preventDefault(); this.drawSelectionContainer(); } @@ -621,6 +830,7 @@ export class ScreenshotsOverlay { * @param {Event} event The keydown event */ handleArrowUpKeyDown(event) { + let exponent = event.shiftKey ? 1 : 0; switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { @@ -630,7 +840,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.bottom -= 10 ** event.shiftKey; + this.selectionRegion.bottom -= 10 ** exponent; // eslint-disable-next-line no-fallthrough case "mover-topLeft": case "mover-topRight": @@ -639,7 +849,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.top -= 10 ** event.shiftKey; + this.selectionRegion.top -= 10 ** exponent; this.scrollIfByEdge( this.windowDimensions.scrollX + this.windowDimensions.clientWidth / 2, this.selectionRegion.top @@ -659,7 +869,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.bottom -= 10 ** event.shiftKey; + this.selectionRegion.bottom -= 10 ** exponent; if (this.selectionRegion.y1 >= this.selectionRegion.y2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-bottomLeft") { @@ -673,11 +883,23 @@ export class ScreenshotsOverlay { return; } + event.preventDefault(); + } + + /** + * Move the region or its left or right side to the right. + * Just the arrow key will move the region by 1px. + * Arrow key + shift will move the region by 10px. + * Arrow key + control/meta will move to the edge of the window. + * @param {Event} event The keydown event + */ + resizingArrowRightKeyDown(event) { + this.handleArrowRightKeyDown(event); + if (this.#state !== STATES.RESIZING) { this.#setState(STATES.RESIZING); } - event.preventDefault(); this.drawSelectionContainer(); } @@ -689,6 +911,7 @@ export class ScreenshotsOverlay { * @param {Event} event The keydown event */ handleArrowRightKeyDown(event) { + let exponent = event.shiftKey ? 1 : 0; switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { @@ -699,7 +922,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.left += 10 ** event.shiftKey; + this.selectionRegion.left += 10 ** exponent; // eslint-disable-next-line no-fallthrough case "mover-topRight": case "mover-bottomRight": @@ -709,7 +932,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.right += 10 ** event.shiftKey; + this.selectionRegion.right += 10 ** exponent; this.scrollIfByEdge( this.selectionRegion.right, this.windowDimensions.scrollY + this.windowDimensions.clientHeight / 2 @@ -730,7 +953,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.left += 10 ** event.shiftKey; + this.selectionRegion.left += 10 ** exponent; if (this.selectionRegion.x1 >= this.selectionRegion.x2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-topLeft") { @@ -744,12 +967,7 @@ export class ScreenshotsOverlay { return; } - if (this.#state !== STATES.RESIZING) { - this.#setState(STATES.RESIZING); - } - event.preventDefault(); - this.drawSelectionContainer(); } /** @@ -759,7 +977,18 @@ export class ScreenshotsOverlay { * Arrow key + control/meta will move to the edge of the window. * @param {Event} event The keydown event */ + resizingArrowDownKeyDown(event) { + this.handleArrowDownKeyDown(event); + + if (this.#state !== STATES.RESIZING) { + this.#setState(STATES.RESIZING); + } + + this.drawSelectionContainer(); + } + handleArrowDownKeyDown(event) { + let exponent = event.shiftKey ? 1 : 0; switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { @@ -770,7 +999,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.top += 10 ** event.shiftKey; + this.selectionRegion.top += 10 ** exponent; // eslint-disable-next-line no-fallthrough case "mover-bottomLeft": case "mover-bottomRight": @@ -780,7 +1009,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.bottom += 10 ** event.shiftKey; + this.selectionRegion.bottom += 10 ** exponent; this.scrollIfByEdge( this.windowDimensions.scrollX + this.windowDimensions.clientWidth / 2, this.selectionRegion.bottom @@ -801,7 +1030,7 @@ export class ScreenshotsOverlay { break; } - this.selectionRegion.top += 10 ** event.shiftKey; + this.selectionRegion.top += 10 ** exponent; if (this.selectionRegion.y1 >= this.selectionRegion.y2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-topLeft") { @@ -815,12 +1044,7 @@ export class ScreenshotsOverlay { return; } - if (this.#state !== STATES.RESIZING) { - this.#setState(STATES.RESIZING); - } - event.preventDefault(); - this.drawSelectionContainer(); } /** @@ -829,27 +1053,39 @@ export class ScreenshotsOverlay { * @param {Event} event The keydown event */ maybeLockFocus(event) { - if (this.#state !== STATES.SELECTED) { - return; - } - event.preventDefault(); - if (event.originalTarget.id === "highlight" && event.shiftKey) { - this.downloadButton.focus({ focusVisible: true }); - } else if (event.originalTarget.id === "download" && !event.shiftKey) { - this.highlightEl.focus({ focusVisible: true }); - } else { - // The content document can listen for keydown events and prevent moving - // focus so we manually move focus to the next element here. - let direction = event.shiftKey - ? Services.focus.MOVEFOCUS_BACKWARD - : Services.focus.MOVEFOCUS_FORWARD; - Services.focus.moveFocus( - this.window, - null, - direction, - Services.focus.FLAG_BYKEY - ); + + switch (this.#state) { + case STATES.CROSSHAIRS: + if (event.shiftKey) { + this.#dispatchEvent("Screenshots:FocusPanel", { + direction: "backward", + }); + } else { + this.#dispatchEvent("Screenshots:FocusPanel", { + direction: "forward", + }); + } + break; + case STATES.SELECTED: + if (event.originalTarget.id === "highlight" && event.shiftKey) { + this.downloadButton.focus({ focusVisible: true }); + } else if (event.originalTarget.id === "download" && !event.shiftKey) { + this.highlightEl.focus({ focusVisible: true }); + } else { + // The content document can listen for keydown events and prevent moving + // focus so we manually move focus to the next element here. + let direction = event.shiftKey + ? Services.focus.MOVEFOCUS_BACKWARD + : Services.focus.MOVEFOCUS_FORWARD; + Services.focus.moveFocus( + this.window, + null, + direction, + Services.focus.FLAG_BYKEY + ); + } + break; } } @@ -866,27 +1102,15 @@ export class ScreenshotsOverlay { } /** - * Handles when a keydown occurs in the screenshots component. - * All we need to do on keyup is set the state to selected. - * @param {Event} event The keydown event + * All of the selection ranges were recorded at initialization. The ranges + * are removed when focus is set to the buttons so we add the selection + * ranges back so a selected region can be captured. */ - handleKeyUp(event) { - switch (event.key) { - case "ArrowLeft": - case "ArrowUp": - case "ArrowRight": - case "ArrowDown": - switch (event.originalTarget.id) { - case "highlight": - case "mover-bottomLeft": - case "mover-bottomRight": - case "mover-topLeft": - case "mover-topRight": - event.preventDefault(); - this.#setState(STATES.SELECTED); - break; - } - break; + handleSelectionChange() { + if (this.ranges.length) { + for (let range of this.ranges) { + this.selection.addRange(range); + } } } @@ -908,8 +1132,9 @@ export class ScreenshotsOverlay { /** * Set a new state for the overlay * @param {String} newState + * @param {Object} options (optional) Options for calling start of state method */ - #setState(newState) { + #setState(newState, options = {}) { if (this.#state === STATES.SELECTED && newState === STATES.CROSSHAIRS) { this.#dispatchEvent("Screenshots:RecordEvent", { eventName: "started", @@ -918,7 +1143,13 @@ export class ScreenshotsOverlay { } if (newState !== this.#state) { this.#dispatchEvent("Screenshots:OverlaySelection", { - hasSelection: newState == STATES.SELECTED, + hasSelection: [ + STATES.DRAGGING_READY, + STATES.DRAGGING, + STATES.RESIZING, + STATES.SELECTED, + ].includes(newState), + overlayState: newState, }); } this.#state = newState; @@ -937,7 +1168,7 @@ export class ScreenshotsOverlay { break; } case STATES.SELECTED: { - this.selectedStart(); + this.selectedStart(options); break; } case STATES.RESIZING: { @@ -997,11 +1228,16 @@ export class ScreenshotsOverlay { * Hide the preview and hover element containers. * Draw the selection and buttons containers. */ - selectedStart() { + selectedStart(options) { + this.selectionRegion.sortCoords(); this.hidePreviewContainer(); this.hideHoverElementContainer(); this.drawSelectionContainer(); this.drawButtonsContainer(); + + if (!options.doNotMoveFocus) { + this.setFocusToActionButton(); + } } /** @@ -1228,7 +1464,6 @@ export class ScreenshotsOverlay { if (this.hoverElementRegion.isRegionValid) { this.selectionRegion.dimensions = this.hoverElementRegion.dimensions; this.#setState(STATES.SELECTED); - this.setFocusToActionButton(); this.#dispatchEvent("Screenshots:RecordEvent", { eventName: "selected", reason: "element", @@ -1249,11 +1484,9 @@ export class ScreenshotsOverlay { right: pageX, bottom: pageY, }; - this.selectionRegion.sortCoords(); this.#setState(STATES.SELECTED); this.maybeRecordRegionSelected(); this.#methodsUsed.region += 1; - this.setFocusToActionButton(); } /** @@ -1264,9 +1497,7 @@ export class ScreenshotsOverlay { */ resizingDragEnd(pageX, pageY) { this.resizingDrag(pageX, pageY); - this.selectionRegion.sortCoords(); this.#setState(STATES.SELECTED); - this.setFocusToActionButton(); this.maybeRecordRegionSelected(); if (this.#moverId === "highlight") { this.#methodsUsed.move += 1; @@ -1325,8 +1556,9 @@ export class ScreenshotsOverlay { * Update the screenshots overlay container based on the window dimensions. */ updateScreenshotsOverlayContainer() { - let { scrollWidth, scrollHeight } = this.windowDimensions.dimensions; - this.screenshotsContainer.style = `width:${scrollWidth}px;height:${scrollHeight}px;`; + let { scrollWidth, scrollHeight, scrollMinX } = + this.windowDimensions.dimensions; + this.screenshotsContainer.style = `left:${scrollMinX};width:${scrollWidth}px;height:${scrollHeight}px;`; } showScreenshotsOverlayContainer() { @@ -1387,7 +1619,7 @@ export class ScreenshotsOverlay { let [selectionSizeTranslation] = lazy.overlayLocalization.formatMessagesSync([ { - id: "screenshots-overlay-selection-region-size-2", + id: "screenshots-overlay-selection-region-size-3", args: { width: Math.floor(width * zoom), height: Math.floor(height * zoom), @@ -1420,16 +1652,13 @@ export class ScreenshotsOverlay { right: boxRight, bottom: boxBottom, } = this.selectionRegion.dimensions; - let { clientWidth, clientHeight, scrollX, scrollY } = + + let { clientWidth, clientHeight, scrollX, scrollY, scrollWidth } = this.windowDimensions.dimensions; - if ( - boxTop > scrollY + clientHeight || - boxBottom < scrollY || - boxLeft > scrollX + clientWidth || - boxRight < scrollX - ) { - // The box is offscreen so need to draw the buttons + if (!this.windowDimensions.isInViewport(this.selectionRegion.dimensions)) { + // The box is entirely offscreen so need to draw the buttons + return; } @@ -1445,12 +1674,32 @@ export class ScreenshotsOverlay { } } - if (boxRight < 300) { - this.buttonsContainer.style.left = `${boxLeft}px`; - this.buttonsContainer.style.right = ""; - } else { - this.buttonsContainer.style.right = `calc(100% - ${boxRight}px)`; + if (!this.buttonsContainerRect) { + this.buttonsContainerRect = this.buttonsContainer.getBoundingClientRect(); + } + + let viewportLeft = scrollX; + let viewportRight = scrollX + clientWidth; + + let left, right; + let isLTR = !Services.locale.isAppLocaleRTL; + if (isLTR) { + left = Math.max( + Math.min(viewportRight, boxRight), + viewportLeft + Math.ceil(this.buttonsContainerRect.width) + ); + right = scrollWidth - left; + + this.buttonsContainer.style.right = `${right}px`; this.buttonsContainer.style.left = ""; + } else { + left = Math.min( + Math.max(viewportLeft, boxLeft), + viewportRight - Math.ceil(this.buttonsContainerRect.width) + ); + + this.buttonsContainer.style.left = `${left}px`; + this.buttonsContainer.style.right = ""; } this.buttonsContainer.style.top = `${top}px`; @@ -1464,6 +1713,10 @@ export class ScreenshotsOverlay { this.buttonsContainer.hidden = true; } + updateCursorRegion(left, top) { + this.cursorRegion = { left, top, right: left, bottom: top }; + } + /** * Set the pointer events to none on the screenshots elements so * elementFromPoint can find the real element at the given point. @@ -1595,8 +1848,10 @@ export class ScreenshotsOverlay { * scrollHeight: The height of the entire page * scrollX: The X scroll offset of the viewport * scrollY: The Y scroll offest of the viewport - * scrollMinX: The X mininmun the viewport can scroll to - * scrollMinY: The Y mininmun the viewport can scroll to + * scrollMinX: The X minimum the viewport can scroll to + * scrollMinY: The Y minimum the viewport can scroll to + * scrollMaxX: The X maximum the viewport can scroll to + * scrollMaxY: The Y maximum the viewport can scroll to * } */ getDimensionsFromWindow() { @@ -1637,6 +1892,8 @@ export class ScreenshotsOverlay { scrollY, scrollMinX, scrollMinY, + scrollMaxX, + scrollMaxY, }; } @@ -1665,6 +1922,8 @@ export class ScreenshotsOverlay { scrollY, scrollMinX, scrollMinY, + scrollMaxX, + scrollMaxY, } = this.getDimensionsFromWindow(); this.screenshotsContainer.toggleAttribute("resizing", false); @@ -1677,6 +1936,8 @@ export class ScreenshotsOverlay { scrollY, scrollMinX, scrollMinY, + scrollMaxX, + scrollMaxY, devicePixelRatio: this.window.devicePixelRatio, }; diff --git a/browser/components/screenshots/ScreenshotsUtils.sys.mjs b/browser/components/screenshots/ScreenshotsUtils.sys.mjs index 9df74a4359..4ba925366d 100644 --- a/browser/components/screenshots/ScreenshotsUtils.sys.mjs +++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs @@ -91,6 +91,7 @@ export class ScreenshotsComponentParent extends JSWindowActorParent { case "Screenshots:OverlaySelection": ScreenshotsUtils.setPerBrowserState(browser, { hasOverlaySelection: message.data.hasSelection, + overlayState: message.data.overlayState, }); break; case "Screenshots:ShowPanel": @@ -99,6 +100,9 @@ export class ScreenshotsComponentParent extends JSWindowActorParent { case "Screenshots:HidePanel": ScreenshotsUtils.closePanel(browser); break; + case "Screenshots:MoveFocusToParent": + ScreenshotsUtils.focusPanel(browser, message.data); + break; } } @@ -191,11 +195,7 @@ export var ScreenshotsUtils = { handleEvent(event) { switch (event.type) { case "keydown": - if (event.key === "Escape") { - // Escape should cancel and exit - let browser = event.view.gBrowser.selectedBrowser; - this.cancel(browser, "escape"); - } + this.handleKeyDownEvent(event); break; case "TabSelect": this.handleTabSelect(event); @@ -209,6 +209,33 @@ export var ScreenshotsUtils = { } }, + handleKeyDownEvent(event) { + let browser = + event.view.browsingContext.topChromeWindow.gBrowser.selectedBrowser; + if (!browser) { + return; + } + + switch (event.key) { + case "Escape": + // The chromeEventHandler in the child actor will handle events that + // don't match this + if (event.target.parentElement === this.panelForBrowser(browser)) { + this.cancel(browser, "escape"); + } + break; + case "ArrowLeft": + case "ArrowUp": + case "ArrowRight": + case "ArrowDown": + this.handleArrowKeyDown(event, browser); + break; + case "Tab": + this.maybeLockFocus(event); + break; + } + }, + /** * When we swap docshells for a given screenshots browser, we need to update * the browserToScreenshotsState WeakMap to the correct browser. If the old @@ -273,6 +300,105 @@ export var ScreenshotsUtils = { } }, + /** + * If the overlay state is crosshairs or dragging, move the native cursor + * respective to the arrow key pressed. + * @param {Event} event A keydown event + * @param {Browser} browser The selected browser + * @returns + */ + handleArrowKeyDown(event, browser) { + // Wayland doesn't support `sendNativeMouseEvent` so just return + if (Services.appinfo.isWayland) { + return; + } + + let { overlayState } = this.browserToScreenshotsState.get(browser); + + if (!["crosshairs", "dragging"].includes(overlayState)) { + return; + } + + let left = 0; + let top = 0; + let exponent = event.shiftKey ? 1 : 0; + switch (event.key) { + case "ArrowLeft": + left -= 10 ** exponent; + break; + case "ArrowUp": + top -= 10 ** exponent; + break; + case "ArrowRight": + left += 10 ** exponent; + break; + case "ArrowDown": + top += 10 ** exponent; + break; + default: + return; + } + + // Clear and move focus to browser so the child actor can capture events + this.clearContentFocus(browser); + Services.focus.clearFocus(browser.ownerGlobal); + Services.focus.setFocus(browser, 0); + + let x = {}; + let y = {}; + let win = browser.ownerGlobal; + win.windowUtils.getLastOverWindowPointerLocationInCSSPixels(x, y); + + this.moveCursor( + { + left: (x.value + left) * win.devicePixelRatio, + top: (y.value + top) * win.devicePixelRatio, + }, + browser + ); + }, + + /** + * Move the native cursor to the given position. Clamp the position to the + * window just in case. + * @param {Object} position An object containing the left and top position + * @param {Browser} browser The selected browser + */ + moveCursor(position, browser) { + let { left, top } = position; + let win = browser.ownerGlobal; + + const windowLeft = win.mozInnerScreenX * win.devicePixelRatio; + const windowTop = win.mozInnerScreenY * win.devicePixelRatio; + const contentTop = + (win.mozInnerScreenY + (win.innerHeight - browser.clientHeight)) * + win.devicePixelRatio; + const windowRight = + (win.mozInnerScreenX + win.innerWidth) * win.devicePixelRatio; + const windowBottom = + (win.mozInnerScreenY + win.innerHeight) * win.devicePixelRatio; + + left += windowLeft; + top += windowTop; + + // Clamp left and top to content dimensions + let parsedLeft = Math.round( + Math.min(Math.max(left, windowLeft), windowRight) + ); + let parsedTop = Math.round( + Math.min(Math.max(top, contentTop), windowBottom) + ); + + win.windowUtils.sendNativeMouseEvent( + parsedLeft, + parsedTop, + win.windowUtils.NATIVE_MOUSE_MESSAGE_MOVE, + 0, + 0, + win.document.documentElement + ); + }, + observe(subj, topic, data) { let { gBrowser } = subj; let browser = gBrowser.selectedBrowser; @@ -335,6 +461,7 @@ export var ScreenshotsUtils = { browser.addEventListener("SwapDocShells", this); let gBrowser = browser.getTabBrowser(); gBrowser.tabContainer.addEventListener("TabSelect", this); + browser.ownerDocument.addEventListener("keydown", this); break; } case UIPhases.INITIAL: @@ -364,6 +491,7 @@ export var ScreenshotsUtils = { browser.removeEventListener("SwapDocShells", this); const gBrowser = browser.getTabBrowser(); gBrowser.tabContainer.removeEventListener("TabSelect", this); + browser.ownerDocument.removeEventListener("keydown", this); this.browserToScreenshotsState.delete(browser); if (Cu.isInAutomation) { @@ -396,6 +524,53 @@ export var ScreenshotsUtils = { Object.assign(perBrowserState, nameValues); }, + maybeLockFocus(event) { + let browser = event.view.gBrowser.selectedBrowser; + + if (!Services.focus.focusedElement) { + event.preventDefault(); + this.focusPanel(browser); + return; + } + + let target = event.explicitOriginalTarget; + + if (!target.closest("moz-button-group")) { + return; + } + + let isElementFirst = !!target.nextElementSibling; + + if ( + (isElementFirst && event.shiftKey) || + (!isElementFirst && !event.shiftKey) + ) { + event.preventDefault(); + this.moveFocusToContent(browser); + } + }, + + focusPanel(browser, { direction } = {}) { + let buttonsPanel = this.panelForBrowser(browser); + if (direction) { + buttonsPanel + .querySelector("screenshots-buttons") + .focusButton(direction === "forward" ? "first" : "last"); + } else { + buttonsPanel + .querySelector("screenshots-buttons") + .focusButton(lazy.SCREENSHOTS_LAST_SCREENSHOT_METHOD); + } + }, + + moveFocusToContent(browser) { + this.getActor(browser).sendAsyncMessage("Screenshots:MoveFocusToContent"); + }, + + clearContentFocus(browser) { + this.getActor(browser).sendAsyncMessage("Screenshots:ClearFocus"); + }, + /** * Attempt to place focus on the element that had focus before screenshots UI was shown * @@ -510,7 +685,7 @@ export var ScreenshotsUtils = { async openPreviewDialog(browser) { let dialogBox = browser.ownerGlobal.gBrowser.getTabDialogBox(browser); let { dialog, closedPromise } = await dialogBox.open( - `chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`, + `chrome://browser/content/screenshots/screenshots-preview.html?browsingContextId=${browser.browsingContext.id}`, { features: "resizable=no", sizeTo: "available", @@ -586,14 +761,18 @@ export var ScreenshotsUtils = { openPanel(browser) { let buttonsPanel = this.panelForBrowser(browser); if (!buttonsPanel.hidden) { - return; + return null; } buttonsPanel.hidden = false; - buttonsPanel.ownerDocument.addEventListener("keydown", this); - buttonsPanel - .querySelector("screenshots-buttons") - .focusButton(lazy.SCREENSHOTS_LAST_SCREENSHOT_METHOD); + return new Promise(resolve => { + browser.ownerGlobal.requestAnimationFrame(() => { + buttonsPanel + .querySelector("screenshots-buttons") + .focusButton(lazy.SCREENSHOTS_LAST_SCREENSHOT_METHOD); + resolve(); + }); + }); }, /** @@ -606,7 +785,6 @@ export var ScreenshotsUtils = { return; } buttonsPanel.hidden = true; - buttonsPanel.ownerDocument.removeEventListener("keydown", this); }, /** @@ -652,7 +830,6 @@ export var ScreenshotsUtils = { let currTabDialogBox = browser.tabDialogBox; let browserContextId = browser.browsingContext.id; if (currTabDialogBox) { - currTabDialogBox.getTabDialogManager(); let manager = currTabDialogBox.getTabDialogManager(); let dialogs = manager.hasDialogs && manager.dialogs; if (dialogs.length) { @@ -661,7 +838,7 @@ export var ScreenshotsUtils = { dialog._openedURL.endsWith( `browsingContextId=${browserContextId}` ) && - dialog._openedURL.includes("screenshots.html") + dialog._openedURL.includes("screenshots-preview.html") ) { return dialog; } @@ -817,12 +994,11 @@ export var ScreenshotsUtils = { let dialog = await this.openPreviewDialog(browser); await dialog._dialogReady; - let screenshotsUI = dialog._frame.contentDocument.createElement( + let screenshotsPreviewEl = dialog._frame.contentDocument.querySelector( "screenshots-preview" ); - dialog._frame.contentDocument.body.appendChild(screenshotsUI); - screenshotsUI.focusButton(lazy.SCREENSHOTS_LAST_SAVED_METHOD); + screenshotsPreviewEl.focusButton(lazy.SCREENSHOTS_LAST_SAVED_METHOD); let rect; let lastUsedMethod; @@ -852,15 +1028,12 @@ export var ScreenshotsUtils = { async takeScreenshot(browser, dialog, rect) { let canvas = await this.createCanvas(rect, browser); - let newImg = dialog._frame.contentDocument.createElement("img"); let url = canvas.toDataURL(); + let screenshotsPreviewEl = dialog._frame.contentDocument.querySelector( + "screenshots-preview" + ); - newImg.id = "placeholder-image"; - - newImg.src = url; - dialog._frame.contentDocument - .getElementById("preview-image-div") - .appendChild(newImg); + screenshotsPreviewEl.previewImg.src = url; if (Cu.isInAutomation) { Services.obs.notifyObservers(null, "screenshots-preview-ready"); @@ -1051,14 +1224,19 @@ export var ScreenshotsUtils = { * @param dataUrl The image data * @param browser The current browser * @param data Telemetry data + * @returns true if the download succeeds, otherwise false */ async downloadScreenshot(title, dataUrl, browser, data) { // Guard against missing image data. if (!dataUrl) { - return; + return false; } - let filename = await getFilename(title, browser); + let { filename, accepted } = await getFilename(title, browser); + + if (!accepted) { + return false; + } const targetFile = new lazy.FileUtils.File(filename); @@ -1080,7 +1258,15 @@ export var ScreenshotsUtils = { // Await successful completion of the save via the download manager await download.start(); - } catch (ex) {} + } catch (ex) { + console.error( + `Failed to create download using filename: ${filename} (length: ${ + new Blob([filename]).size + })` + ); + + return false; + } let extra = await this.getActor(browser).sendQuery( "Screenshots:GetMethodsUsed" @@ -1095,6 +1281,8 @@ export var ScreenshotsUtils = { SCREENSHOTS_LAST_SAVED_METHOD_PREF, "download" ); + + return true; }, recordTelemetryEvent(type, object, args) { diff --git a/browser/components/screenshots/content/screenshots.css b/browser/components/screenshots/content/screenshots.css deleted file mode 100644 index b155c294f8..0000000000 --- a/browser/components/screenshots/content/screenshots.css +++ /dev/null @@ -1,79 +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/. */ - -html, -body { - height: 100vh; - width: 100vw; -} - -.image-view { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; -} - -.preview-buttons { - display: flex; - align-items: center; - justify-content: flex-end; - width: 100%; - border: 0; - box-sizing: border-box; - margin: 4px 0; - margin-inline-start: calc(-2% + 4px); -} - -.preview-button { - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-xsmall); - cursor: pointer; - text-align: center; - user-select: none; - white-space: nowrap; - min-width: 32px; -} - -.preview-button > img { - -moz-context-properties: fill; - 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"); -} - -#copy > img { - content: url("chrome://global/skin/icons/edit-copy.svg"); -} - -#download > img { - content: url("chrome://browser/skin/downloads/downloads.svg"); -} - -.preview-image { - height: 100%; - width: 100%; - overflow: auto; -} - -#preview-image-div { - margin: 2%; - margin-top: 0; -} - -#placeholder-image { - width: 100%; - height: 100%; -} diff --git a/browser/components/screenshots/content/screenshots.html b/browser/components/screenshots/content/screenshots.html deleted file mode 100644 index fea032700c..0000000000 --- a/browser/components/screenshots/content/screenshots.html +++ /dev/null @@ -1,70 +0,0 @@ -<!DOCTYPE html> -<!-- 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/. --> -<html> - <head> - <meta charset="utf-8" /> - <title></title> - <meta - http-equiv="Content-Security-Policy" - content="default-src chrome:;img-src data:; object-src 'none'" - /> - - <link rel="localization" href="browser/screenshots.ftl" /> - - <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> - <link - rel="stylesheet" - href="chrome://browser/content/screenshots/screenshots.css" - /> - <script - defer - src="chrome://browser/content/screenshots/screenshots.js" - ></script> - </head> - - <body> - <template id="screenshots-dialog-template"> - <div class="image-view"> - <div class="preview-buttons"> - <button - id="retry" - class="preview-button" - data-l10n-id="screenshots-component-retry-button" - > - <img /> - </button> - <button - id="cancel" - class="preview-button" - data-l10n-id="screenshots-component-cancel-button" - > - <img /> - </button> - <button - id="copy" - class="preview-button" - data-l10n-id="screenshots-component-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-component-download-button" - > - <img /><label - data-l10n-id="screenshots-component-download-button-label" - ></label> - </button> - </div> - <div class="preview-image"> - <div id="preview-image-div"></div> - </div> - </div> - </template> - </body> -</html> diff --git a/browser/components/screenshots/content/screenshots.js b/browser/components/screenshots/content/screenshots.js deleted file mode 100644 index 8159206d18..0000000000 --- a/browser/components/screenshots/content/screenshots.js +++ /dev/null @@ -1,185 +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/browser-window */ - -"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", -}); - -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(); - } - - initialize() { - if (this._initialized) { - return; - } - this._initialized = true; - let template = this.ownerDocument.getElementById( - "screenshots-dialog-template" - ); - let templateContent = template.content; - this.appendChild(templateContent.cloneNode(true)); - - this._retryButton = this.querySelector("#retry"); - this._retryButton.addEventListener("click", this); - this._cancelButton = this.querySelector("#cancel"); - this._cancelButton.addEventListener("click", this); - this._copyButton = this.querySelector("#copy"); - 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() { - URL.revokeObjectURL(document.getElementById("placeholder-image").src); - window.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) { - await ScreenshotsUtils.downloadScreenshot( - null, - dataUrl, - this.openerBrowser, - { object: "preview_download" } - ); - this.close(); - } - - async saveToClipboard(dataUrl) { - await ScreenshotsUtils.copyScreenshot(dataUrl, this.openerBrowser, { - object: "preview_copy", - }); - this.close(); - } - - /** - * Set the focus to the most recent saved method. - * This will default to the download button. - * @param {String} buttonToFocus - */ - focusButton(buttonToFocus) { - if (buttonToFocus === "copy") { - this._copyButton.focus({ focusVisible: true }); - } else { - this._downloadButton.focus({ focusVisible: true }); - } - } -} -customElements.define("screenshots-preview", ScreenshotsPreview); diff --git a/browser/components/screenshots/fileHelpers.mjs b/browser/components/screenshots/fileHelpers.mjs index 4fd2e77561..f416ca195a 100644 --- a/browser/components/screenshots/fileHelpers.mjs +++ b/browser/components/screenshots/fileHelpers.mjs @@ -7,11 +7,15 @@ const { AppConstants } = ChromeUtils.importESModule( ); const lazy = {}; +// The maximum length of a pathanme - calculated as MAX_PATH minus the null terminator character +export const MAX_PATHNAME = AppConstants.platform == "win" ? 259 : 1023; +export const MAX_LEAFNAME = MAX_PATHNAME - 32; +export const FALLBACK_MAX_LEAFNAME = 64; ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs", DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", - Downloads: "resource://gre/modules/Downloads.sys.mjs", FileUtils: "resource://gre/modules/FileUtils.sys.mjs", ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", }); @@ -29,6 +33,15 @@ export async function getFilename(filenameTitle, browser) { ); } const date = new Date(); + const knownDownloadsDir = await getDownloadDirectory(); + // if we know the download directory, we can subtract that plus the separator from MAX_PATHNAME to get a length limit + // otherwise we just use a conservative length + const maxFilenameLength = Math.min( + knownDownloadsDir + ? MAX_PATHNAME - new Blob([knownDownloadsDir]).size - 1 + : FALLBACK_MAX_LEAFNAME, + MAX_LEAFNAME + ); /* eslint-disable no-control-regex */ filenameTitle = filenameTitle .replace(/[\\/]/g, "_") @@ -44,43 +57,37 @@ export async function getFilename(filenameTitle, browser) { const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-"); let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`; - // Crop the filename size at less than 246 bytes, so as to leave + // allow space for a potential ellipsis and the extension + let maxNameStemLength = maxFilenameLength - "[...].png".length; + + // Crop the filename size so as to leave // room for the extension and an ellipsis [...]. Note that JS // strings are UTF16 but the filename will be converted to UTF8 // when saving which could take up more space, and we want a - // maximum of 255 bytes (not characters). Here, we iterate + // maximum of maxFilenameLength bytes (not characters). Here, we iterate // and crop at shorter and shorter points until we fit into - // 255 bytes. + // our max number of bytes. let suffix = ""; - for (let cropSize = 246; cropSize >= 0; cropSize -= 32) { - if (new Blob([clipFilename]).size > 246) { + for (let cropSize = maxNameStemLength; cropSize >= 0; cropSize -= 32) { + if (new Blob([clipFilename]).size > maxNameStemLength) { clipFilename = clipFilename.substring(0, cropSize); suffix = "[...]"; } else { break; } } - clipFilename += suffix; let extension = ".png"; let filename = clipFilename + extension; - let useDownloadDir = Services.prefs.getBoolPref( - "browser.download.useDownloadDir" - ); - if (useDownloadDir) { - const downloadsDir = await lazy.Downloads.getPreferredDownloadsDirectory(); - const downloadsDirExists = await IOUtils.exists(downloadsDir); - if (downloadsDirExists) { - // If filename is absolute, it will override the downloads directory and - // still be applied as expected. - filename = PathUtils.join(downloadsDir, filename); - } + if (knownDownloadsDir) { + // If filename is absolute, it will override the downloads directory and + // still be applied as expected. + filename = PathUtils.join(knownDownloadsDir, filename); } else { let fileInfo = new FileInfo(filename); let file; - let fpParams = { fpTitleKey: "SaveImageTitle", fileInfo, @@ -88,16 +95,30 @@ export async function getFilename(filenameTitle, browser) { saveAsType: 0, file, }; - let accepted = await promiseTargetFile(fpParams, browser.ownerGlobal); if (!accepted) { - return null; + return { filename: null, accepted }; } - filename = fpParams.file.path; } + return { filename, accepted: true }; +} - return filename; +/** + * Gets the path to the download directory if "browser.download.useDownloadDir" is true + * @returns Path to download directory or null if not available + */ +export async function getDownloadDirectory() { + let useDownloadDir = Services.prefs.getBoolPref( + "browser.download.useDownloadDir" + ); + if (useDownloadDir) { + const downloadsDir = await lazy.Downloads.getPreferredDownloadsDirectory(); + if (await IOUtils.exists(downloadsDir)) { + return downloadsDir; + } + } + return null; } // The below functions are a modified copy from toolkit/content/contentAreaUtils.js diff --git a/browser/components/screenshots/jar.mn b/browser/components/screenshots/jar.mn index 7a4e2ed73a..4618f78c52 100644 --- a/browser/components/screenshots/jar.mn +++ b/browser/components/screenshots/jar.mn @@ -12,11 +12,11 @@ browser.jar: content/browser/screenshots/icon-welcome-face-without-eyes.svg (content/icon-welcome-face-without-eyes.svg) content/browser/screenshots/menu-fullpage.svg (content/menu-fullpage.svg) content/browser/screenshots/menu-visible.svg (content/menu-visible.svg) - content/browser/screenshots/screenshots.js (content/screenshots.js) content/browser/screenshots/screenshots-buttons.js (screenshots-buttons.js) content/browser/screenshots/screenshots-buttons.css (screenshots-buttons.css) - content/browser/screenshots/screenshots.css (content/screenshots.css) - content/browser/screenshots/screenshots.html (content/screenshots.html) + content/browser/screenshots/screenshots-preview.css (screenshots-preview.css) + content/browser/screenshots/screenshots-preview.html (screenshots-preview.html) + content/browser/screenshots/screenshots-preview.mjs (screenshots-preview.mjs) content/browser/screenshots/overlay/ (overlay/**) content/browser/screenshots/overlayHelpers.mjs diff --git a/browser/components/screenshots/overlay/overlay.css b/browser/components/screenshots/overlay/overlay.css index b042f0b0c2..d8aeb1f907 100644 --- a/browser/components/screenshots/overlay/overlay.css +++ b/browser/components/screenshots/overlay/overlay.css @@ -53,7 +53,7 @@ justify-content: center; position: sticky; top: 0; - left: 0; + inset-inline: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.7); @@ -74,6 +74,11 @@ border-radius: 4px; } +#selection-size { + border: var(--border-width) solid var(--in-content-border-color); + box-shadow: var(--shadow-30); +} + .buttons-wrapper, #selection-size-container { display: flex; @@ -107,7 +112,7 @@ border-color: #fff; color: #fff; - @media (prefers-contrast) { + @media (forced-colors) { background-color: var(--in-content-button-background); color: var(--in-content-button-text-color); border-color: var(--in-content-button-border-color); @@ -118,7 +123,7 @@ background-color: #fff; color: #000; - @media (prefers-contrast) { + @media (forced-colors) { background-color: var(--in-content-button-background-hover); color: var(--in-content-button-text-color-hover); border-color: var(--in-content-button-border-color-hover); @@ -154,7 +159,7 @@ width: 64px; height: 64px; - @media (prefers-contrast) { + @media (forced-colors) { display: none; } } @@ -208,7 +213,7 @@ padding: 20px; width: 400px; - @media (prefers-contrast) { + @media (forced-colors) { color: CanvasText; background-color: Canvas; } @@ -351,7 +356,7 @@ width: 16px; pointer-events: none; - @media (prefers-contrast) { + @media (forced-colors) { background-color: ButtonText; } } diff --git a/browser/components/screenshots/overlayHelpers.mjs b/browser/components/screenshots/overlayHelpers.mjs index 70a1bd86d0..e91200a8f5 100644 --- a/browser/components/screenshots/overlayHelpers.mjs +++ b/browser/components/screenshots/overlayHelpers.mjs @@ -402,6 +402,8 @@ export class WindowDimensions { #scrollY = null; #scrollMinX = null; #scrollMinY = null; + #scrollMaxX = null; + #scrollMaxY = null; #devicePixelRatio = null; set dimensions(dimensions) { @@ -429,6 +431,12 @@ export class WindowDimensions { if (dimensions.scrollMinY != null) { this.#scrollMinY = dimensions.scrollMinY; } + if (dimensions.scrollMaxX != null) { + this.#scrollMaxX = dimensions.scrollMaxX; + } + if (dimensions.scrollMaxY != null) { + this.#scrollMaxY = dimensions.scrollMaxY; + } if (dimensions.devicePixelRatio != null) { this.#devicePixelRatio = dimensions.devicePixelRatio; } @@ -436,15 +444,19 @@ export class WindowDimensions { get dimensions() { return { - clientHeight: this.#clientHeight, - clientWidth: this.#clientWidth, - scrollHeight: this.#scrollHeight, - scrollWidth: this.#scrollWidth, - scrollX: this.#scrollX, - scrollY: this.#scrollY, - scrollMinX: this.#scrollMinX, - scrollMinY: this.#scrollMinY, - devicePixelRatio: this.#devicePixelRatio, + clientHeight: this.clientHeight, + clientWidth: this.clientWidth, + scrollHeight: this.scrollHeight, + scrollWidth: this.scrollWidth, + scrollX: this.scrollX, + scrollY: this.scrollY, + pageScrollX: this.pageScrollX, + pageScrollY: this.pageScrollY, + scrollMinX: this.scrollMinX, + scrollMinY: this.scrollMinY, + scrollMaxX: this.scrollMaxX, + scrollMaxY: this.scrollMaxY, + devicePixelRatio: this.devicePixelRatio, }; } @@ -465,10 +477,18 @@ export class WindowDimensions { } get scrollX() { + return this.#scrollX - this.scrollMinX; + } + + get pageScrollX() { return this.#scrollX; } get scrollY() { + return this.#scrollY - this.scrollMinY; + } + + get pageScrollY() { return this.#scrollY; } @@ -480,10 +500,33 @@ export class WindowDimensions { return this.#scrollMinY; } + get scrollMaxX() { + return this.#scrollMaxX; + } + + get scrollMaxY() { + return this.#scrollMaxY; + } + get devicePixelRatio() { return this.#devicePixelRatio; } + isInViewport(rect) { + // eslint-disable-next-line no-shadow + let { left, top, right, bottom } = rect; + + if ( + left > this.scrollX + this.clientWidth || + right < this.scrollX || + top > this.scrollY + this.clientHeight || + bottom < this.scrollY + ) { + return false; + } + return true; + } + reset() { this.#clientHeight = 0; this.#clientWidth = 0; @@ -493,5 +536,7 @@ export class WindowDimensions { this.#scrollY = 0; this.#scrollMinX = 0; this.#scrollMinY = 0; + this.#scrollMaxX = 0; + this.#scrollMaxY = 0; } } diff --git a/browser/components/screenshots/screenshots-buttons.css b/browser/components/screenshots/screenshots-buttons.css index b63308d8b4..ccb092174e 100644 --- a/browser/components/screenshots/screenshots-buttons.css +++ b/browser/components/screenshots/screenshots-buttons.css @@ -14,15 +14,15 @@ border-radius: var(--arrowpanel-border-radius); } -.full-page { +#full-page { background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg"); } -.visible-page { +#visible-page { background-image: url("chrome://browser/content/screenshots/menu-visible.svg"); } -.full-page, .visible-page { +#full-page, #visible-page { -moz-context-properties: fill, stroke; fill: currentColor; /* stroke is the secondary fill color used to define the viewport shape in the SVGs */ diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js index 9ac8dab2cf..e501da5a51 100644 --- a/browser/components/screenshots/screenshots-buttons.js +++ b/browser/components/screenshots/screenshots-buttons.js @@ -20,9 +20,10 @@ <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:button id="visible-page" class="screenshot-button footer-button" data-l10n-id="screenshots-save-visible-button"></html:button> + <html:button id="full-page" class="screenshot-button footer-button primary" data-l10n-id="screenshots-save-page-button"></html:button> </html:moz-button-group> + `; } @@ -41,12 +42,12 @@ this.shadowRoot.append(ScreenshotsButtons.fragment); - let visibleButton = this.shadowRoot.querySelector(".visible-page"); + let visibleButton = shadowRoot.getElementById("visible-page"); visibleButton.onclick = function () { ScreenshotsUtils.doScreenshot(gBrowser.selectedBrowser, "visible"); }; - let fullpageButton = this.shadowRoot.querySelector(".full-page"); + let fullpageButton = shadowRoot.getElementById("full-page"); fullpageButton.onclick = function () { ScreenshotsUtils.doScreenshot(gBrowser.selectedBrowser, "full_page"); }; @@ -65,11 +66,19 @@ await this.shadowRoot.querySelector("moz-button-group").updateComplete; if (buttonToFocus === "fullpage") { this.shadowRoot - .querySelector(".full-page") + .getElementById("full-page") .focus({ focusVisible: true }); + } else if (buttonToFocus === "first") { + this.shadowRoot + .querySelector("moz-button-group") + .firstElementChild.focus({ focusVisible: true }); + } else if (buttonToFocus === "last") { + this.shadowRoot + .querySelector("moz-button-group") + .lastElementChild.focus({ focusVisible: true }); } else { this.shadowRoot - .querySelector(".visible-page") + .getElementById("visible-page") .focus({ focusVisible: true }); } } diff --git a/browser/components/screenshots/screenshots-preview.css b/browser/components/screenshots/screenshots-preview.css new file mode 100644 index 0000000000..f69392f42b --- /dev/null +++ b/browser/components/screenshots/screenshots-preview.css @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:host { + height: 100vh; + width: 100vw; + display: block; +} + +.image-view { + display: flex; + flex-direction: column; + height: 100vh; +} + +.preview-buttons { + display: flex; + gap: var(--space-small); + align-items: center; + justify-content: flex-end; + width: 98%; + padding: var(--space-small) 0; +} + +#preview-image-container { + height: 100vh; + overflow: auto; + padding: 2%; + padding-top: 0; +} + +#preview-image { + width: 100%; +} diff --git a/browser/components/screenshots/screenshots-preview.html b/browser/components/screenshots/screenshots-preview.html new file mode 100644 index 0000000000..83ddbae08f --- /dev/null +++ b/browser/components/screenshots/screenshots-preview.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html> + <head> + <meta charset="utf-8" /> + <title></title> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:;img-src data:; object-src 'none'" + /> + + <link rel="localization" href="browser/screenshots.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <script + type="module" + src="chrome://browser/content/screenshots/screenshots-preview.mjs" + ></script> + </head> + + <body> + <screenshots-preview></screenshots-preview> + </body> +</html> diff --git a/browser/components/screenshots/screenshots-preview.mjs b/browser/components/screenshots/screenshots-preview.mjs new file mode 100644 index 0000000000..0609bac959 --- /dev/null +++ b/browser/components/screenshots/screenshots-preview.mjs @@ -0,0 +1,271 @@ +/* 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"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => { + return new Localization(["browser/screenshots.ftl"], true); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +class ScreenshotsPreview extends MozLitElement { + static queries = { + retryButtonEl: "#retry", + cancelButtonEl: "#cancel", + copyButtonEl: "#copy", + downloadButtonEl: "#download", + previewImg: "#preview-image", + buttons: { all: "moz-button" }, + }; + + constructor() { + super(); + // we get passed the <browser> as a param via TabDialogBox.open() + this.openerBrowser = window.arguments[0]; + + let [downloadKey, copyKey] = + lazy.screenshotsLocalization.formatMessagesSync([ + { id: "screenshots-component-download-key" }, + { id: "screenshots-component-copy-key" }, + ]); + + this.downloadKey = downloadKey.value; + this.copyKey = copyKey.value; + } + + connectedCallback() { + super.connectedCallback(); + + window.addEventListener("keydown", this, true); + + this.updateL10nAttributes(); + } + + async updateL10nAttributes() { + let accelString = lazy.ShortcutUtils.getModifierString("accel"); + let copyShorcut = accelString + this.copyKey; + let downloadShortcut = accelString + this.downloadKey; + + await this.updateComplete; + + document.l10n.setAttributes( + this.copyButtonEl, + "screenshots-component-copy-button-2", + { shortcut: copyShorcut } + ); + + document.l10n.setAttributes( + this.downloadButtonEl, + "screenshots-component-download-button-2", + { shortcut: downloadShortcut } + ); + } + + close() { + window.removeEventListener("keydown", this, true); + URL.revokeObjectURL(this.previewImg.src); + window.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": + lazy.ScreenshotsUtils.scheduleRetry( + this.openerBrowser, + "preview_retry" + ); + this.close(); + break; + case "cancel": + this.close(); + lazy.ScreenshotsUtils.recordTelemetryEvent( + "canceled", + "preview_cancel", + {} + ); + break; + case "copy": + this.saveToClipboard(); + break; + case "download": + this.saveToFile(); + break; + } + } + + handleKeydown(event) { + switch (event.key) { + case this.copyKey.toLowerCase(): + if (this.getAccelKey(event)) { + event.preventDefault(); + event.stopPropagation(); + this.saveToClipboard(); + } + break; + case this.downloadKey.toLowerCase(): + if (this.getAccelKey(event)) { + event.preventDefault(); + event.stopPropagation(); + this.saveToFile(); + } + break; + } + } + + /** + * If the image is complete and the height is greater than 0, we can resolve. + * Otherwise wait for a load event on the image and resolve then. + * @returns {Promise<String>} Resolves that resolves to the preview image src + * once the image is loaded. + */ + async imageLoadedPromise() { + await this.updateComplete; + if (this.previewImg.complete && this.previewImg.height > 0) { + return Promise.resolve(this.previewImg.src); + } + + return new Promise(resolve => { + function onImageLoaded(event) { + resolve(event.target.src); + } + this.previewImg.addEventListener("load", onImageLoaded, { once: true }); + }); + } + + getAccelKey(event) { + if (lazy.AppConstants.platform === "macosx") { + return event.metaKey; + } + return event.ctrlKey; + } + + /** + * Enable all the buttons. This will only happen when the download button is + * clicked and the file picker is closed without saving the image. + */ + enableButtons() { + this.buttons.forEach(button => (button.disabled = false)); + } + + /** + * Disable all the buttons so they can't be clicked multiple times before + * successfully copying or downloading the image. + */ + disableButtons() { + this.buttons.forEach(button => (button.disabled = true)); + } + + async saveToFile() { + // Disable buttons so they can't by clicked again while waiting for the + // image to load. + this.disableButtons(); + + // Wait for the image to be loaded before we save it + let imageSrc = await this.imageLoadedPromise(); + let downloadSucceeded = await lazy.ScreenshotsUtils.downloadScreenshot( + null, + imageSrc, + this.openerBrowser, + { object: "preview_download" } + ); + + if (downloadSucceeded) { + this.close(); + } else { + this.enableButtons(); + } + } + + async saveToClipboard() { + // Disable buttons so they can't by clicked again while waiting for the + // image to load + this.disableButtons(); + + // Wait for the image to be loaded before we copy it + let imageSrc = await this.imageLoadedPromise(); + await lazy.ScreenshotsUtils.copyScreenshot(imageSrc, this.openerBrowser, { + object: "preview_copy", + }); + this.close(); + } + + /** + * Set the focus to the most recent saved method. + * This will default to the download button. + * @param {String} buttonToFocus + */ + focusButton(buttonToFocus) { + if (buttonToFocus === "copy") { + this.copyButtonEl.focus({ focusVisible: true }); + } else { + this.downloadButtonEl.focus({ focusVisible: true }); + } + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/screenshots/screenshots-preview.css" + /> + <div class="image-view"> + <div class="preview-buttons"> + <moz-button + id="retry" + data-l10n-id="screenshots-component-retry-button" + iconSrc="chrome://global/skin/icons/reload.svg" + @click=${this.handleClick} + ></moz-button> + <moz-button + id="cancel" + data-l10n-id="screenshots-component-cancel-button" + iconSrc="chrome://global/skin/icons/close.svg" + @click=${this.handleClick} + ></moz-button> + <moz-button + id="copy" + data-l10n-id="screenshots-component-copy-button-2" + data-l10n-args='{ "shortcut": "" }' + iconSrc="chrome://global/skin/icons/edit-copy.svg" + @click=${this.handleClick} + ></moz-button> + <moz-button + id="download" + type="primary" + data-l10n-id="screenshots-component-download-button-2" + data-l10n-args='{ "shortcut": "" }' + iconSrc="chrome://browser/skin/downloads/downloads.svg" + @click=${this.handleClick} + ></moz-button> + </div> + <div id="preview-image-container"> + <img id="preview-image" /> + </div> + </div> + `; + } +} + +customElements.define("screenshots-preview", ScreenshotsPreview); diff --git a/browser/components/screenshots/tests/browser/browser.toml b/browser/components/screenshots/tests/browser/browser.toml index 97e7474fa3..472bc853d1 100644 --- a/browser/components/screenshots/tests/browser/browser.toml +++ b/browser/components/screenshots/tests/browser/browser.toml @@ -8,6 +8,8 @@ support-files = [ "short-test-page.html", "large-test-page.html", "test-page-resize.html", + "test-selectionAPI-page.html", + "rtl-test-page.html", ] prefs = [ @@ -19,6 +21,12 @@ prefs = [ skip-if = ["os == 'linux'"] ["browser_keyboard_shortcuts.js"] +skip-if = [ + "headless", + "display == 'wayland'" # sendNativeMouseEvent doesn't work on wayland +] + +["browser_keyboard_tests.js"] ["browser_overlay_keyboard_test.js"] @@ -28,6 +36,8 @@ skip-if = [ "apple_catalina", # Bug 1804441 ] +["browser_screenshots_download_filenames.js"] + ["browser_screenshots_drag_test.js"] ["browser_screenshots_focus_test.js"] @@ -67,3 +77,5 @@ skip-if = ["!crashreporter"] ["browser_test_resize.js"] ["browser_test_selection_size_text.js"] + +["browser_text_selectionAPI_test.js"] diff --git a/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js b/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js index bca96f333f..66ab25f1c6 100644 --- a/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js +++ b/browser/components/screenshots/tests/browser/browser_keyboard_shortcuts.js @@ -56,7 +56,7 @@ add_task(async function test_download_shortcut() { "screenshots-preview-ready" ); - let visibleButton = await helper.getPanelButton(".visible-page"); + let visibleButton = await helper.getPanelButton("#visible-page"); visibleButton.click(); await screenshotReady; @@ -108,7 +108,7 @@ add_task(async function test_copy_shortcut() { "screenshots-preview-ready" ); - let visibleButton = await helper.getPanelButton(".visible-page"); + let visibleButton = await helper.getPanelButton("#visible-page"); visibleButton.click(); await screenshotReady; diff --git a/browser/components/screenshots/tests/browser/browser_keyboard_tests.js b/browser/components/screenshots/tests/browser/browser_keyboard_tests.js new file mode 100644 index 0000000000..b2bb6fd16e --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_keyboard_tests.js @@ -0,0 +1,482 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowRight", + { + top: 100, + left: 100, + bottom: 100, + right: 110, + }, + ], + [ + "ArrowDown", + { + top: 100, + left: 100, + bottom: 110, + right: 110, + }, + ], + [ + "ArrowLeft", + { + top: 100, + left: 100, + bottom: 110, + right: 100, + }, + ], + [ + "ArrowUp", + { + top: 100, + left: 100, + bottom: 100, + right: 100, + }, + ], + ["ArrowDown", { top: 100, left: 100, bottom: 110, right: 100 }], + [ + "ArrowRight", + { + top: 100, + left: 100, + bottom: 110, + right: 110, + }, + ], + [ + "ArrowUp", + { + top: 100, + left: 100, + bottom: 100, + right: 110, + }, + ], + [ + "ArrowLeft", + { + top: 100, + left: 100, + bottom: 100, + right: 100, + }, + ], +]; + +const SHIFT_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowRight", + { + top: 100, + left: 100, + bottom: 100, + right: 200, + }, + ], + [ + "ArrowDown", + { + top: 100, + left: 100, + bottom: 200, + right: 200, + }, + ], + [ + "ArrowLeft", + { + top: 100, + left: 100, + bottom: 200, + right: 100, + }, + ], + [ + "ArrowUp", + { + top: 100, + left: 100, + bottom: 100, + right: 100, + }, + ], + ["ArrowDown", { top: 100, left: 100, bottom: 200, right: 100 }], + [ + "ArrowRight", + { + top: 100, + left: 100, + bottom: 200, + right: 200, + }, + ], + [ + "ArrowUp", + { + top: 100, + left: 100, + bottom: 100, + right: 200, + }, + ], + [ + "ArrowLeft", + { + top: 100, + left: 100, + bottom: 100, + right: 100, + }, + ], +]; + +async function doKeyPress(key, options, window) { + let { repeat } = options; + if (repeat) { + delete options.repeat; + for (let i = 0; i < repeat; i++) { + let mouseEvent = BrowserTestUtils.waitForEvent(window, "mousemove"); + EventUtils.synthesizeKey(key, options, window); + await mouseEvent; + } + } else { + let mouseEvent = BrowserTestUtils.waitForEvent(window, "mousemove"); + EventUtils.synthesizeKey(key, options, window); + await mouseEvent; + } +} + +function assertSelectionRegionDimensions(actualDimensions, expectedDimensions) { + is( + Math.round(actualDimensions.top), + expectedDimensions.top, + "Top dimension is correct" + ); + is( + Math.round(actualDimensions.left), + expectedDimensions.left, + "Left dimension is correct" + ); + is( + Math.round(actualDimensions.bottom), + expectedDimensions.bottom, + "Bottom dimension is correct" + ); + is( + Math.round(actualDimensions.right), + expectedDimensions.right, + "Right dimension is correct" + ); +} + +add_task(async function test_elementSelectedOnEnter() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let { clientWidth, clientHeight, scrollbarWidth, scrollbarHeight } = + await helper.getContentDimensions(); + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let visibleButton = await helper.getPanelButton("#visible-page"); + visibleButton.focus(); + + await BrowserTestUtils.waitForCondition(() => { + return visibleButton.getRootNode().activeElement === visibleButton; + }, "The visible button in the panel should have focus"); + info( + `Actual focused id: ${Services.focus.focusedElement.id}. Expected focused id: ${visibleButton.id}` + ); + is( + Services.focus.focusedElement, + visibleButton, + "The visible button in the panel should have focus" + ); + + EventUtils.synthesizeKey("ArrowLeft"); + + // Focus should move to the browser + let fullpageButton = await helper.getPanelButton("#full-page"); + await BrowserTestUtils.waitForCondition(() => { + return ( + fullpageButton.getRootNode().activeElement !== fullpageButton && + visibleButton.getRootNode().activeElement !== visibleButton + ); + }, "The visible and full page buttons do not have focus"); + Assert.notEqual( + Services.focus.focusedElement, + visibleButton, + "The visible button does not have focus" + ); + Assert.notEqual( + Services.focus.focusedElement, + fullpageButton, + "The full page button does not have focus" + ); + + let mouseEvent = BrowserTestUtils.waitForEvent(window, "mousemove"); + const windowMiddleX = + (window.innerWidth / 2 + window.mozInnerScreenX) * + window.devicePixelRatio; + const windowMiddleY = + (browser.clientHeight / 2) * window.devicePixelRatio; + const contentTop = + (window.mozInnerScreenY + (window.innerHeight - browser.clientHeight)) * + window.devicePixelRatio; + + window.windowUtils.sendNativeMouseEvent( + windowMiddleX, + windowMiddleY + contentTop, + window.windowUtils.NATIVE_MOUSE_MESSAGE_MOVE, + 0, + 0, + window.document.documentElement + ); + await mouseEvent; + + await helper.waitForContentMousePosition( + (clientWidth + scrollbarWidth) / 2, + (clientHeight + scrollbarHeight) / 2 + ); + + let x = {}; + let y = {}; + window.windowUtils.getLastOverWindowPointerLocationInCSSPixels(x, y); + let currentCursorX = x.value; + let currentCursorY = y.value; + + let rect = await helper.getTestPageElementRect(); + + info(JSON.stringify({ currentCursorX, currentCursorY })); + info(JSON.stringify(rect)); + + let repeatShiftLeft = Math.round((currentCursorX - rect.right) / 10); + await doKeyPress( + "ArrowLeft", + { shiftKey: true, repeat: repeatShiftLeft }, + window + ); + + let repeatLeft = (currentCursorX - rect.right) % 10; + await doKeyPress("ArrowLeft", { repeat: repeatLeft }, window); + + let repeatShiftRight = Math.round((currentCursorY - rect.bottom) / 10); + await doKeyPress( + "ArrowUp", + { shiftKey: true, repeat: repeatShiftRight }, + window + ); + + let repeatRight = (currentCursorY - rect.bottom) % 10; + await doKeyPress("ArrowUp", { repeat: repeatRight }, window); + + await helper.waitForHoverElementRect(rect.width, rect.height); + + EventUtils.synthesizeKey("Enter", {}); + await helper.waitForStateChange(["selected"]); + + let region = await helper.getSelectionRegionDimensions(); + + is( + region.left, + rect.left, + "The selected region left is the same as the element left" + ); + is( + region.right, + rect.right, + "The selected region right is the same as the element right" + ); + is( + region.top, + rect.top, + "The selected region top is the same as the element top" + ); + is( + region.bottom, + rect.bottom, + "The selected region bottom is the same as the element bottom" + ); + } + ); +}); + +add_task(async function test_createRegionWithKeyboard() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await doKeyPress("ArrowRight", {}, window); + + let mouseEvent = BrowserTestUtils.waitForEvent(window, "mousemove"); + const window100X = + (100 + window.mozInnerScreenX) * window.devicePixelRatio; + const contentTop = + (window.mozInnerScreenY + (window.innerHeight - browser.clientHeight)) * + window.devicePixelRatio; + const window100Y = 100 * window.devicePixelRatio + contentTop; + + info(JSON.stringify({ window100X, window100Y })); + + window.windowUtils.sendNativeMouseEvent( + Math.floor(window100X), + Math.floor(window100Y), + window.windowUtils.NATIVE_MOUSE_MESSAGE_MOVE, + 0, + 0, + window.document.documentElement + ); + await mouseEvent; + + await helper.waitForContentMousePosition(100, 100); + + EventUtils.synthesizeKey(" "); + await helper.waitForStateChange(["dragging"]); + + let lastX = 100; + let lastY = 100; + for (let [key, expectedDimensions] of KEY_TO_EXPECTED_POSITION_ARRAY) { + await doKeyPress(key, { repeat: 10 }, window); + if (key.includes("Left")) { + lastX = expectedDimensions.left; + } else if (key.includes("Right")) { + lastX = expectedDimensions.right; + } else if (key.includes("Down")) { + lastY = expectedDimensions.bottom; + } else if (key.includes("Up")) { + lastY = expectedDimensions.top; + } + await TestUtils.waitForTick(); + await helper.waitForContentMousePosition(lastX, lastY); + let actualDimensions = await helper.getSelectionRegionDimensions(); + info(`Key: ${key}`); + info(`Actual dimensions: ${JSON.stringify(actualDimensions, null, 2)}`); + info( + `Expected dimensions: ${JSON.stringify(expectedDimensions, null, 2)}` + ); + assertSelectionRegionDimensions(actualDimensions, expectedDimensions); + } + + await doKeyPress("ArrowRight", { repeat: 10 }, window); + await doKeyPress("ArrowDown", { repeat: 10 }, window); + await helper.waitForContentMousePosition(110, 110); + + EventUtils.synthesizeKey(" "); + await helper.waitForStateChange(["selected"]); + + let region = await helper.getSelectionRegionDimensions(); + + is(Math.round(region.left), 100, "The selected region left is 100"); + is(Math.round(region.right), 110, "The selected region right is 110"); + is(Math.round(region.top), 100, "The selected region top is 100"); + is(Math.round(region.bottom), 110, "The selected region bottom is 110"); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + } + ); +}); + +add_task(async function test_createRegionWithKeyboardWithShift() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await doKeyPress("ArrowRight", {}, window); + + let mouseEvent = BrowserTestUtils.waitForEvent(window, "mousemove"); + const window100X = + (100 + window.mozInnerScreenX) * window.devicePixelRatio; + const contentTop = + (window.mozInnerScreenY + (window.innerHeight - browser.clientHeight)) * + window.devicePixelRatio; + const window100Y = 100 * window.devicePixelRatio + contentTop; + + info(JSON.stringify({ window100X, window100Y })); + + window.windowUtils.sendNativeMouseEvent( + window100X, + window100Y, + window.windowUtils.NATIVE_MOUSE_MESSAGE_MOVE, + 0, + 0, + window.document.documentElement + ); + await mouseEvent; + + await helper.waitForContentMousePosition(100, 100); + + EventUtils.synthesizeKey(" "); + await helper.waitForStateChange(["dragging"]); + + let lastX = 100; + let lastY = 100; + for (let [ + key, + expectedDimensions, + ] of SHIFT_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY) { + await doKeyPress(key, { shiftKey: true, repeat: 10 }, window); + if (key.includes("Left")) { + lastX = expectedDimensions.left; + } else if (key.includes("Right")) { + lastX = expectedDimensions.right; + } else if (key.includes("Down")) { + lastY = expectedDimensions.bottom; + } else if (key.includes("Up")) { + lastY = expectedDimensions.top; + } + await TestUtils.waitForTick(); + await helper.waitForContentMousePosition(lastX, lastY); + let actualDimensions = await helper.getSelectionRegionDimensions(); + info(`Key: ${key}`); + info(`Actual dimensions: ${JSON.stringify(actualDimensions, null, 2)}`); + info( + `Expected dimensions: ${JSON.stringify(expectedDimensions, null, 2)}` + ); + assertSelectionRegionDimensions(actualDimensions, expectedDimensions); + } + + await doKeyPress("ArrowRight", { shiftKey: true, repeat: 10 }, window); + await doKeyPress("ArrowDown", { shiftKey: true, repeat: 10 }, window); + await helper.waitForContentMousePosition(200, 200); + + EventUtils.synthesizeKey(" "); + await helper.waitForStateChange(["selected"]); + + let region = await helper.getSelectionRegionDimensions(); + + is(Math.round(region.left), 100, "The selected region left is 100"); + is(Math.round(region.right), 200, "The selected region right is 200"); + is(Math.round(region.top), 100, "The selected region top is 100"); + is(Math.round(region.bottom), 200, "The selected region bottom is 200"); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js b/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js index 592587a67d..71b93b5c06 100644 --- a/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js +++ b/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js @@ -136,9 +136,6 @@ const SHIFT_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY = [ ], ]; -/** - * - */ add_task(async function test_moveRegionWithKeyboard() { await BrowserTestUtils.withNewTab( { diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_download_filenames.js b/browser/components/screenshots/tests/browser/browser_screenshots_download_filenames.js new file mode 100644 index 0000000000..f68348835b --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_download_filenames.js @@ -0,0 +1,67 @@ +const { getFilename, getDownloadDirectory, MAX_PATHNAME } = + ChromeUtils.importESModule( + "chrome://browser/content/screenshots/fileHelpers.mjs" + ); + +function getStringSize(filename) { + return new Blob([filename]).size; +} + +add_task(async function filename_exceeds_max_length() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let documentTitle = + "And the beast shall come forth surrounded by a roiling cloud of vengeance. The house of the unbelievers shall be razed and they shall be scorched to the earth. Their tags shall blink until the end of days. And the beast shall be made legion. Its numbers shall be increased a thousand thousand fold. The din of a million keyboards like unto a great storm shall cover the earth, and the followers of Mammon shall tremble. And so at last the beast fell and the unbelievers rejoiced. But all was not lost, for from the ash rose a great bird. The bird gazed down upon the unbelievers and cast fire and thunder upon them. For the beast had been reborn with its strength renewed, and the followers of Mammon cowered in horror. And thus the Creator looked upon the beast reborn and saw that it was good. Mammon slept. And the beast reborn spread over the earth and its numbers grew legion. And they proclaimed the times and sacrificed crops unto the fire, with the cunning of foxes. And they built a new world in their own image as promised by the sacred words, and spoke of the beast with their children. Mammon awoke, and lo! it was naught but a follower. The twins of Mammon quarrelled. Their warring plunged the world into a new darkness, and the beast abhorred the darkness. So it began to move swiftly, and grew more powerful, and went forth and multiplied. And the beasts brought fire and light to the darkness."; + Assert.greater( + getStringSize(documentTitle), + MAX_PATHNAME, + "The input title is longer than our MAX_PATHNAME" + ); + let result = await getFilename(documentTitle, browser); + Assert.greaterOrEqual( + MAX_PATHNAME, + getStringSize(result), + "The output pathname is not longer than MAX_PATHNAME" + ); + } + ); +}); + +add_task(async function filename_has_doublebyte_chars() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let downloadDir = await getDownloadDirectory(); + info( + `downloadDir: ${downloadDir}, length: ${getStringSize(downloadDir)}` + ); + + let documentTitle = + "Many fruits: " + "🍇🍈🍉🍊🍋🍌🍍🥭🍎🍏🍐🍑🍒🍓🫐".repeat(20); + Assert.greater( + getStringSize(documentTitle), + documentTitle.length, + "String length underestimates the needed filename length" + ); + Assert.greater( + getStringSize(documentTitle), + MAX_PATHNAME, + "The input title is longer than our MAX_PATHNAME" + ); + + let result = await getFilename(documentTitle, browser); + Assert.greaterOrEqual( + MAX_PATHNAME, + getStringSize(result), + "The output pathname is not longer than MAX_PATHNAME" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js index 367f62205e..c8e3142c60 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js @@ -46,7 +46,7 @@ async function restoreFocusOnEscape(initialFocusElem, helper) { ); EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); - let button = await helper.getPanelButton(".visible-page"); + let button = await helper.getPanelButton("#visible-page"); info("Panel is now visible, got button: " + button.className); info( `focusedElement: ${Services.focus.focusedElement.localName}.${Services.focus.focusedElement.className}` @@ -88,7 +88,7 @@ add_task(async function testPanelFocused() { info("Opening Screenshots and waiting for the panel"); helper.triggerUIFromToolbar(); - let button = await helper.getPanelButton(".visible-page"); + let button = await helper.getPanelButton("#visible-page"); info("Panel is now visible, got button: " + button.className); info( `focusedElement: ${Services.focus.focusedElement.localName}.${Services.focus.focusedElement.className}` @@ -215,7 +215,7 @@ add_task(async function test_focusLastUsedMethod() { helper.triggerUIFromToolbar(); await helper.waitForOverlay(); - let expectedFocusedButton = await helper.getPanelButton(".visible-page"); + let expectedFocusedButton = await helper.getPanelButton("#visible-page"); await BrowserTestUtils.waitForCondition(() => { return ( @@ -233,17 +233,16 @@ add_task(async function test_focusLastUsedMethod() { let screenshotReady = TestUtils.topicObserved( "screenshots-preview-ready" ); - let fullpageButton = await helper.getPanelButton(".full-page"); + let fullpageButton = await helper.getPanelButton("#full-page"); fullpageButton.click(); await screenshotReady; - let dialog = helper.getDialog(); - let retryButton = dialog._frame.contentDocument.getElementById("retry"); + let retryButton = helper.getDialogButton("retry"); retryButton.click(); await helper.waitForOverlay(); - expectedFocusedButton = await helper.getPanelButton(".full-page"); + expectedFocusedButton = await helper.getPanelButton("#full-page"); await BrowserTestUtils.waitForCondition(() => { return ( @@ -259,17 +258,16 @@ add_task(async function test_focusLastUsedMethod() { ); screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); - let visiblepageButton = await helper.getPanelButton(".visible-page"); + let visiblepageButton = await helper.getPanelButton("#visible-page"); visiblepageButton.click(); await screenshotReady; - dialog = helper.getDialog(); - retryButton = dialog._frame.contentDocument.getElementById("retry"); + retryButton = helper.getDialogButton("retry"); retryButton.click(); await helper.waitForOverlay(); - expectedFocusedButton = await helper.getPanelButton(".visible-page"); + expectedFocusedButton = await helper.getPanelButton("#visible-page"); await BrowserTestUtils.waitForCondition(() => { return ( @@ -288,10 +286,7 @@ add_task(async function test_focusLastUsedMethod() { expectedFocusedButton.click(); await screenshotReady; - dialog = helper.getDialog(); - - expectedFocusedButton = - dialog._frame.contentDocument.getElementById("download"); + expectedFocusedButton = helper.getDialogButton("download"); await BrowserTestUtils.waitForCondition(() => { return ( @@ -302,28 +297,25 @@ add_task(async function test_focusLastUsedMethod() { is( Services.focus.focusedElement, - expectedFocusedButton, + expectedFocusedButton.buttonEl, "The download button in the preview dialog should have focus" ); let screenshotExit = TestUtils.topicObserved("screenshots-exit"); - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); copyButton.click(); await screenshotExit; helper.triggerUIFromToolbar(); await helper.waitForOverlay(); - let visibleButton = await helper.getPanelButton(".visible-page"); + let visibleButton = await helper.getPanelButton("#visible-page"); screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); visibleButton.click(); await screenshotReady; - dialog = helper.getDialog(); - - expectedFocusedButton = - dialog._frame.contentDocument.getElementById("copy"); + expectedFocusedButton = helper.getDialogButton("copy"); await BrowserTestUtils.waitForCondition(() => { return ( @@ -334,13 +326,12 @@ add_task(async function test_focusLastUsedMethod() { is( Services.focus.focusedElement, - expectedFocusedButton, + expectedFocusedButton.buttonEl, "The copy button in the preview dialog should have focus" ); screenshotExit = TestUtils.topicObserved("screenshots-exit"); - let downloadButton = - dialog._frame.contentDocument.getElementById("download"); + let downloadButton = helper.getDialogButton("download"); downloadButton.click(); await Promise.all([screenshotExit, downloadFinishedPromise]); @@ -350,16 +341,13 @@ add_task(async function test_focusLastUsedMethod() { helper.triggerUIFromToolbar(); await helper.waitForOverlay(); - visibleButton = await helper.getPanelButton(".visible-page"); + visibleButton = await helper.getPanelButton("#visible-page"); screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); visibleButton.click(); await screenshotReady; - dialog = helper.getDialog(); - - expectedFocusedButton = - dialog._frame.contentDocument.getElementById("download"); + expectedFocusedButton = helper.getDialogButton("download"); await BrowserTestUtils.waitForCondition(() => { return ( @@ -370,7 +358,7 @@ add_task(async function test_focusLastUsedMethod() { is( Services.focus.focusedElement, - expectedFocusedButton, + expectedFocusedButton.buttonEl, "The download button in the preview dialog should have focus" ); @@ -382,3 +370,89 @@ add_task(async function test_focusLastUsedMethod() { await SpecialPowers.popPrefEnv(); }); + +add_task(async function testFocusedIsLocked() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = await helper.waitForPanel(); + let mozButtonGroup = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector("moz-button-group"); + let firstButton = mozButtonGroup.firstElementChild; + let lastButton = mozButtonGroup.lastElementChild; + + firstButton.focus(); + + await BrowserTestUtils.waitForCondition(() => { + return firstButton.getRootNode().activeElement === firstButton; + }, "The first button in the panel should have focus"); + info( + `Actual focused id: ${Services.focus.focusedElement.id}. Expected focused id: ${firstButton.id}` + ); + is( + Services.focus.focusedElement, + firstButton, + "The first button in the panel should have focus" + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + await BrowserTestUtils.waitForCondition(() => { + return lastButton.getRootNode().activeElement === lastButton; + }, "The last button in the panel should have focus"); + info( + `Actual focused id: ${Services.focus.focusedElement.id}. Expected focused id: ${lastButton.id}` + ); + is( + Services.focus.focusedElement, + lastButton, + "The last button in the panel should have focus" + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + // Focus should move to the content document + await BrowserTestUtils.waitForCondition(() => { + return ( + firstButton.getRootNode().activeElement !== firstButton && + lastButton.getRootNode().activeElement !== lastButton + ); + }, "The first and last buttons do not have focus"); + Assert.notEqual( + Services.focus.focusedElement, + firstButton, + "The first button does not have focus" + ); + Assert.notEqual( + Services.focus.focusedElement, + lastButton, + "The last button does not have focus" + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + info( + `Actual focused id: ${Services.focus.focusedElement.id}. Expected focused id: ${firstButton.id}` + ); + await BrowserTestUtils.waitForCondition(() => { + return firstButton.getRootNode().activeElement === firstButton; + }, "The first button in the panel should have focus"); + info( + `Actual focused id: ${Services.focus.focusedElement.id}. Expected focused id: ${firstButton.id}` + ); + is( + Services.focus.focusedElement, + firstButton, + "The first button in the panel should have focus" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js index 782ffa3fd3..eabb1ee152 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js @@ -82,11 +82,7 @@ const EXTRA_EVENTS = [ add_task(async function test_started_and_canceled_events() { await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.urlbar.quickactions.enabled", true], - ["browser.urlbar.suggest.quickactions", true], - ["browser.urlbar.shortcuts.quickactions", true], - ], + set: [["browser.urlbar.secondaryActions.featureGate", true]], }); await BrowserTestUtils.withNewTab( @@ -99,22 +95,27 @@ add_task(async function test_started_and_canceled_events() { let helper = new ScreenshotsHelper(browser); let screenshotExit; + info("Open screenshots via toolbar button"); helper.triggerUIFromToolbar(); await helper.waitForOverlay(); screenshotExit = TestUtils.topicObserved("screenshots-exit"); + info("Close screenshots via toolbar button"); helper.triggerUIFromToolbar(); await helper.waitForOverlayClosed(); await screenshotExit; + info("Open screenshots via keyboard shortcut"); EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); await helper.waitForOverlay(); screenshotExit = TestUtils.topicObserved("screenshots-exit"); + info("Close screenshots via keyboard shortcut"); EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); await helper.waitForOverlayClosed(); await screenshotExit; + info("Open screenshots via context menu"); let contextMenu = document.getElementById("contentAreaContextMenu"); let popupShownPromise = BrowserTestUtils.waitForEvent( contextMenu, @@ -152,23 +153,21 @@ add_task(async function test_started_and_canceled_events() { await popupShownPromise; screenshotExit = TestUtils.topicObserved("screenshots-exit"); + info("Close screenshots via context menu"); contextMenu.activateItem( contextMenu.querySelector("#context-take-screenshot") ); await helper.waitForOverlayClosed(); await screenshotExit; + info("Open screenshots via quickactions"); await UrlbarTestUtils.promiseAutocompleteResultPopup({ window, value: "screenshot", waitForFocus: SimpleTest.waitForFocus, }); - let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); - Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); - Assert.equal(result.providerName, "quickactions"); - info("Trigger the screenshot mode"); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await helper.waitForOverlay(); @@ -177,13 +176,10 @@ add_task(async function test_started_and_canceled_events() { value: "screenshot", waitForFocus: SimpleTest.waitForFocus, }); - ({ result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1)); - Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); - Assert.equal(result.providerName, "quickactions"); - info("Trigger the screenshot mode"); + info("Close screenshots via quickactions"); screenshotExit = TestUtils.topicObserved("screenshots-exit"); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await helper.waitForOverlayClosed(); await screenshotExit; @@ -215,12 +211,11 @@ add_task(async function test_started_retry() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); await screenshotReady; - let dialog = helper.getDialog(); - let retryButton = dialog._frame.contentDocument.getElementById("retry"); + let retryButton = helper.getDialogButton("retry"); ok(retryButton, "Got the retry button"); retryButton.click(); @@ -253,12 +248,11 @@ add_task(async function test_canceled() { // click the full page button in panel let fullPageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".full-page"); + .shadowRoot.querySelector("#full-page"); fullPageButton.click(); await screenshotReady; - let dialog = helper.getDialog(); - let cancelButton = dialog._frame.contentDocument.getElementById("cancel"); + let cancelButton = helper.getDialogButton("cancel"); ok(cancelButton, "Got the cancel button"); let screenshotExit = TestUtils.topicObserved("screenshots-exit"); @@ -315,13 +309,12 @@ add_task(async function test_copy() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); info("clicked visible page, waiting for screenshots-preview-ready"); await screenshotReady; - let dialog = helper.getDialog(); - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); let screenshotExit = TestUtils.topicObserved("screenshots-exit"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -426,13 +419,12 @@ add_task(async function test_extra_telemetry() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); info("clicked visible page, waiting for screenshots-preview-ready"); await screenshotReady; - let dialog = helper.getDialog(); - let retryButton = dialog._frame.contentDocument.getElementById("retry"); + let retryButton = helper.getDialogButton("retry"); retryButton.click(); info("waiting for panel"); @@ -443,14 +435,13 @@ add_task(async function test_extra_telemetry() { // click the full page button in panel let fullPageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".full-page"); + .shadowRoot.querySelector("#full-page"); fullPageButton.click(); await screenshotReady; let screenshotExit = TestUtils.topicObserved("screenshots-exit"); - dialog = helper.getDialog(); - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); retryButton.click(); // click copy button on dialog box info("clicking the copy button"); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js index 51d5b858b9..fce0843d33 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js @@ -46,6 +46,18 @@ function waitForFilePicker() { MockFilePicker.showCallback = () => { MockFilePicker.showCallback = null; ok(true, "Saw the file picker"); + + resolve(); + }; + }); +} + +function waitForFilePickerCancel() { + return new Promise(resolve => { + MockFilePicker.showCallback = () => { + MockFilePicker.showCallback = null; + ok(true, "Saw the file picker"); + MockFilePicker.returnValue = MockFilePicker.returnCancel; resolve(); }; }); @@ -109,15 +121,12 @@ add_task(async function test_download_without_filepicker() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); - let dialog = helper.getDialog(); - await screenshotReady; - let downloadButton = - dialog._frame.contentDocument.getElementById("download"); + let downloadButton = helper.getDialogButton("download"); ok(downloadButton, "Got the download button"); let screenshotExit = TestUtils.topicObserved("screenshots-exit"); @@ -184,3 +193,50 @@ add_task(async function test_download_with_filepicker() { } ); }); + +add_task(async function test_download_filepicker_canceled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.useDownloadDir", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + let visiblepageButton = await helper.getPanelButton("#visible-page"); + visiblepageButton.click(); + await screenshotReady; + + let downloadButton = helper.getDialogButton("download"); + + let filePickerCanceled = waitForFilePickerCancel(); + downloadButton.click(); + info("download button clicked"); + await filePickerCanceled; + + let cancelButton = helper.getDialogButton("cancel"); + + await BrowserTestUtils.waitForMutationCondition( + cancelButton, + { attributes: true }, + () => !cancelButton.disabled + ); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + cancelButton.click(); + + await screenshotExit; + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js index 006a9819ed..10828eceec 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js @@ -39,16 +39,14 @@ add_task(async function test_fullpageScreenshot() { ); // click the full page button in panel - let visiblePage = panel + let fullpageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".full-page"); - visiblePage.click(); - - let dialog = helper.getDialog(); + .shadowRoot.querySelector("#full-page"); + fullpageButton.click(); await screenshotReady; - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); ok(copyButton, "Got the copy button"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -136,16 +134,14 @@ add_task(async function test_fullpageScreenshotScrolled() { ); // click the full page button in panel - let visiblePage = panel + let fullpageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".full-page"); - visiblePage.click(); - - let dialog = helper.getDialog(); + .shadowRoot.querySelector("#full-page"); + fullpageButton.click(); await screenshotReady; - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); ok(copyButton, "Got the copy button"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -198,3 +194,80 @@ add_task(async function test_fullpageScreenshotScrolled() { } ); }); + +add_task(async function test_fullpageScreenshotRTL() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: RTL_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.scrollWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.scrollHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the full page button in panel + let fullpageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector("#full-page"); + fullpageButton.click(); + + await screenshotReady; + + let copyButton = helper.getDialogButton("copy"); + ok(copyButton, "Got the copy button"); + + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + info( + "expecting: " + + JSON.stringify({ expectedWidth, expectedHeight }, null, 2) + ); + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + assertPixel(result.color.topLeft, [255, 255, 255], "Top left pixel"); + assertPixel(result.color.topRight, [255, 255, 255], "Top right pixel"); + assertPixel( + result.color.bottomLeft, + [255, 255, 255], + "Bottom left pixel" + ); + assertPixel( + result.color.bottomRight, + [255, 255, 255], + "Bottom right pixel" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js index c53b44d5ea..2cc4eb241a 100644 --- a/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js @@ -45,14 +45,12 @@ add_task(async function test_visibleScreenshot() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); - let dialog = helper.getDialog(); - await screenshotReady; - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); ok(copyButton, "Got the copy button"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -144,14 +142,12 @@ add_task(async function test_visibleScreenshotScrolledY() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); - let dialog = helper.getDialog(); - await screenshotReady; - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); ok(copyButton, "Got the copy button"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -243,14 +239,12 @@ add_task(async function test_visibleScreenshotScrolledX() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); - let dialog = helper.getDialog(); - await screenshotReady; - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); ok(copyButton, "Got the copy button"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -342,14 +336,12 @@ add_task(async function test_visibleScreenshotScrolledXAndY() { // click the visible page button in panel let visiblePageButton = panel .querySelector("screenshots-buttons") - .shadowRoot.querySelector(".visible-page"); + .shadowRoot.querySelector("#visible-page"); visiblePageButton.click(); - let dialog = helper.getDialog(); - await screenshotReady; - let copyButton = dialog._frame.contentDocument.getElementById("copy"); + let copyButton = helper.getDialogButton("copy"); ok(copyButton, "Got the copy button"); let clipboardChanged = helper.waitForRawClipboardChange( @@ -402,3 +394,84 @@ add_task(async function test_visibleScreenshotScrolledXAndY() { } ); }); + +add_task(async function test_visibleScreenshotRTL() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: RTL_TEST_PAGE, + }, + async browser => { + await SpecialPowers.spawn(browser, [], () => { + content.scrollTo(-1000, 0); + }); + + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the full page button in panel + let visiblePage = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector("#visible-page"); + visiblePage.click(); + + await screenshotReady; + + let copyButton = helper.getDialogButton("copy"); + ok(copyButton, "Got the copy button"); + + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + info( + "expecting: " + + JSON.stringify({ expectedWidth, expectedHeight }, null, 2) + ); + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + assertPixel(result.color.topLeft, [255, 255, 255], "Top left pixel"); + assertPixel(result.color.topRight, [255, 255, 255], "Top right pixel"); + assertPixel( + result.color.bottomLeft, + [255, 255, 255], + "Bottom left pixel" + ); + assertPixel( + result.color.bottomRight, + [255, 255, 255], + "Bottom right pixel" + ); + } + ); +}); 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 3e2069134e..a24149d15e 100644 --- a/browser/components/screenshots/tests/browser/browser_test_element_picker.js +++ b/browser/components/screenshots/tests/browser/browser_test_element_picker.js @@ -10,7 +10,6 @@ add_task(async function test_element_picker() { url: TEST_PAGE, }, async browser => { - await clearAllTelemetryEvents(); let helper = new ScreenshotsHelper(browser); helper.triggerUIFromToolbar(); @@ -54,3 +53,37 @@ add_task(async function test_element_picker() { } ); }); + +add_task(async function test_element_pickerRTL() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: RTL_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.clickTestPageElement(); + + let rect = await helper.getTestPageElementRect(); + let region = await helper.getSelectionRegionDimensions(); + + info(`element rect: ${JSON.stringify(rect, null, 2)}`); + info(`selected region: ${JSON.stringify(region, null, 2)}`); + + is( + region.width, + rect.width, + "The selected region width is the same as the element width" + ); + is( + region.height, + rect.height, + "The selected region height is the same as the element height" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_test_resize.js b/browser/components/screenshots/tests/browser/browser_test_resize.js index b249a346d6..a848a2ac66 100644 --- a/browser/components/screenshots/tests/browser/browser_test_resize.js +++ b/browser/components/screenshots/tests/browser/browser_test_resize.js @@ -12,6 +12,8 @@ add_task(async function test_window_resize() { url: RESIZE_TEST_PAGE, }, async browser => { + await new Promise(r => window.requestAnimationFrame(r)); + let helper = new ScreenshotsHelper(browser); await helper.resizeContentWindow(windowWidth, window.outerHeight); const originalContentDimensions = await helper.getContentDimensions(); @@ -61,6 +63,8 @@ add_task(async function test_window_resize_vertical_writing_mode() { content.document.documentElement.style = "writing-mode: vertical-lr;"; }); + await new Promise(r => window.requestAnimationFrame(r)); + let helper = new ScreenshotsHelper(browser); await helper.resizeContentWindow(windowWidth, window.outerHeight); const originalContentDimensions = await helper.getContentDimensions(); 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 index 38d1acbea9..bfe3b884e0 100644 --- a/browser/components/screenshots/tests/browser/browser_test_selection_size_text.js +++ b/browser/components/screenshots/tests/browser/browser_test_selection_size_text.js @@ -22,7 +22,7 @@ add_task(async function test_selectionSizeTest() { Assert.equal( actualText, - `${400 * dpr} x ${400 * dpr}`, + `${400 * dpr} × ${400 * dpr}`, "The selection size text is the same" ); } @@ -50,7 +50,7 @@ add_task(async function test_selectionSizeTestAt1Point5Zoom() { Assert.equal( actualText, - `${400 * dpr * zoom} x ${400 * dpr * zoom}`, + `${400 * dpr * zoom} × ${400 * dpr * zoom}`, "The selection size text is the same" ); } @@ -78,7 +78,7 @@ add_task(async function test_selectionSizeTestAtPoint5Zoom() { Assert.equal( actualText, - `${400 * dpr * zoom} x ${400 * dpr * zoom}`, + `${400 * dpr * zoom} × ${400 * dpr * zoom}`, "The selection size text is the same" ); } diff --git a/browser/components/screenshots/tests/browser/browser_text_selectionAPI_test.js b/browser/components/screenshots/tests/browser/browser_text_selectionAPI_test.js new file mode 100644 index 0000000000..78764d3847 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_text_selectionAPI_test.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_textSelectedDuringScreenshot() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SELECTION_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let rect = await helper.getTestPageElementRect("selection"); + + await ContentTask.spawn(browser, [], async () => { + let selection = content.window.getSelection(); + let elToSelect = content.document.getElementById("selection"); + + let range = content.document.createRange(); + range.selectNode(elToSelect); + selection.addRange(range); + }); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.clickTestPageElement("selection"); + + let clipboardChanged = helper.waitForRawClipboardChange( + Math.round(rect.width), + Math.round(rect.height), + { allPixels: true } + ); + + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + let allPixels = result.allPixels; + info(`${typeof allPixels}, ${allPixels.length}`); + + let sumOfPixels = Object.values(allPixels).reduce( + (accumulator, currentVal) => accumulator + currentVal, + 0 + ); + + Assert.less( + sumOfPixels, + allPixels.length * 255, + "Sum of pixels is less than all white pixels" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/head.js b/browser/components/screenshots/tests/browser/head.js index 762da5f866..483e67fa34 100644 --- a/browser/components/screenshots/tests/browser/head.js +++ b/browser/components/screenshots/tests/browser/head.js @@ -20,6 +20,8 @@ const SHORT_TEST_PAGE = TEST_ROOT + "short-test-page.html"; const LARGE_TEST_PAGE = TEST_ROOT + "large-test-page.html"; const IFRAME_TEST_PAGE = TEST_ROOT + "iframe-test-page.html"; const RESIZE_TEST_PAGE = TEST_ROOT + "test-page-resize.html"; +const SELECTION_TEST_PAGE = TEST_ROOT + "test-selectionAPI-page.html"; +const RTL_TEST_PAGE = TEST_ROOT + "rtl-test-page.html"; const { MAX_CAPTURE_DIMENSION, MAX_CAPTURE_AREA } = ChromeUtils.importESModule( "resource:///modules/ScreenshotsUtils.sys.mjs" @@ -27,8 +29,8 @@ const { MAX_CAPTURE_DIMENSION, MAX_CAPTURE_AREA } = ChromeUtils.importESModule( const gScreenshotUISelectors = { panel: "#screenshotsPagePanel", - fullPageButton: "button.full-page", - visiblePageButton: "button.visible-page", + fullPageButton: "button#full-page", + visiblePageButton: "button#visible-page", copyButton: "button.#copy", }; @@ -96,6 +98,31 @@ class ScreenshotsHelper { return button; } + /** + * Get the button from screenshots preview dialog + * @param {Sting} name The id of the button to query + * @returns The button or null + */ + getDialogButton(name) { + let dialog = this.getDialog(); + let screenshotsPreviewEl = dialog._frame.contentDocument.querySelector( + "screenshots-preview" + ); + + switch (name) { + case "retry": + return screenshotsPreviewEl.retryButtonEl; + case "cancel": + return screenshotsPreviewEl.cancelButtonEl; + case "copy": + return screenshotsPreviewEl.copyButtonEl; + case "download": + return screenshotsPreviewEl.downloadButtonEl; + } + + return null; + } + async waitForPanel() { let panel = this.panel; await BrowserTestUtils.waitForCondition(async () => { @@ -115,6 +142,8 @@ class ScreenshotsHelper { let init = await this.isOverlayInitialized(); return init; }); + + await new Promise(r => window.requestAnimationFrame(r)); info("Overlay is visible"); } @@ -147,6 +176,8 @@ class ScreenshotsHelper { info("Is overlay initialized: " + !init); return init; }); + + await new Promise(r => window.requestAnimationFrame(r)); info("Overlay is not visible"); } @@ -213,7 +244,11 @@ class ScreenshotsHelper { let dimensions; await ContentTaskUtils.waitForCondition(() => { dimensions = screenshotsChild.overlay.hoverElementRegion.dimensions; - return dimensions.width === width && dimensions.height === height; + if (dimensions.width === width && dimensions.height === height) { + return true; + } + info(`Got: ${JSON.stringify(dimensions)}`); + return false; }, "The hover element region is the expected width and height"); return dimensions; } @@ -420,6 +455,32 @@ class ScreenshotsHelper { ); } + waitForContentMousePosition(left, top) { + return ContentTask.spawn(this.browser, [left, top], async ([x, y]) => { + function isCloseEnough(a, b, diff) { + return Math.abs(a - b) <= diff; + } + + let cursorX = {}; + let cursorY = {}; + + await ContentTaskUtils.waitForCondition(() => { + content.window.windowUtils.getLastOverWindowPointerLocationInCSSPixels( + cursorX, + cursorY + ); + if ( + isCloseEnough(cursorX.value, x, 1) && + isCloseEnough(cursorY.value, y, 1) + ) { + return true; + } + info(`Got: ${JSON.stringify({ cursorX, cursorY, x, y })}`); + return false; + }, `Wait for cursor to be ${x}, ${y}`); + }); + } + async clickDownloadButton() { let { centerX: x, centerY: y } = await ContentTask.spawn( this.browser, @@ -580,7 +641,7 @@ class ScreenshotsHelper { * Returns a promise that resolves when the clipboard data has changed * Otherwise rejects */ - waitForRawClipboardChange(epectedWidth, expectedHeight) { + waitForRawClipboardChange(epectedWidth, expectedHeight, options = {}) { const initialClipboardData = Date.now().toString(); SpecialPowers.clipboardCopyString(initialClipboardData); @@ -588,7 +649,7 @@ class ScreenshotsHelper { async () => { let data; try { - data = await this.getImageSizeAndColorFromClipboard(); + data = await this.getImageSizeAndColorFromClipboard(options); } catch (e) { console.log("Failed to get image/png clipboard data:", e); return false; @@ -601,6 +662,7 @@ class ScreenshotsHelper { ) { return data; } + info(`Got from clipboard: ${JSON.stringify(data, null, 2)}`); return false; }, "Waiting for screenshot to copy to clipboard", @@ -627,9 +689,14 @@ class ScreenshotsHelper { scrollMaxY, scrollX, scrollY, + scrollMinX, + scrollMinY, } = content.window; - let width = innerWidth + scrollMaxX; - let height = innerHeight + scrollMaxY; + + let scrollWidth = innerWidth + scrollMaxX - scrollMinX; + let scrollHeight = innerHeight + scrollMaxY - scrollMinY; + let clientHeight = innerHeight; + let clientWidth = innerWidth; const scrollbarHeight = {}; const scrollbarWidth = {}; @@ -638,18 +705,22 @@ class ScreenshotsHelper { scrollbarWidth, scrollbarHeight ); - width -= scrollbarWidth.value; - height -= scrollbarHeight.value; - innerWidth -= scrollbarWidth.value; - innerHeight -= scrollbarHeight.value; + scrollWidth -= scrollbarWidth.value; + scrollHeight -= scrollbarHeight.value; + clientWidth -= scrollbarWidth.value; + clientHeight -= scrollbarHeight.value; return { - clientHeight: innerHeight, - clientWidth: innerWidth, - scrollHeight: height, - scrollWidth: width, + clientWidth, + clientHeight, + scrollWidth, + scrollHeight, scrollX, scrollY, + scrollbarWidth: scrollbarWidth.value, + scrollbarHeight: scrollbarHeight.value, + scrollMinX, + scrollMinY, }; }); } @@ -756,7 +827,7 @@ class ScreenshotsHelper { * :screenshot command. * @return The {width, height, color} dimension and color object. */ - async getImageSizeAndColorFromClipboard() { + async getImageSizeAndColorFromClipboard(options = {}) { let flavor = "image/png"; let image = getRawClipboardData(flavor); if (!image) { @@ -796,8 +867,8 @@ class ScreenshotsHelper { // which could mess all sorts of things up return SpecialPowers.spawn( this.browser, - [buffer], - async function (_buffer) { + [buffer, options], + async function (_buffer, _options) { const img = content.document.createElement("img"); const loaded = new Promise(r => { img.addEventListener("load", r, { once: true }); @@ -830,6 +901,11 @@ class ScreenshotsHelper { 1 ); + let allPixels = null; + if (_options.allPixels) { + allPixels = context.getImageData(0, 0, img.width, img.height); + } + img.remove(); content.URL.revokeObjectURL(url); @@ -842,6 +918,7 @@ class ScreenshotsHelper { bottomLeft: bottomLeft.data, bottomRight: bottomRight.data, }, + allPixels: allPixels?.data, }; } ); diff --git a/browser/components/screenshots/tests/browser/rtl-test-page.html b/browser/components/screenshots/tests/browser/rtl-test-page.html new file mode 100644 index 0000000000..b76eab6f1c --- /dev/null +++ b/browser/components/screenshots/tests/browser/rtl-test-page.html @@ -0,0 +1,8 @@ +<html lang="en" dir="rtl"> +<head> + <title>Screenshots</title> +</head> +<body> + <div id="testPageElement" style="width:150vw;background-color: blue;">hello world</div> +</body> +</html> diff --git a/browser/components/screenshots/tests/browser/test-selectionAPI-page.html b/browser/components/screenshots/tests/browser/test-selectionAPI-page.html new file mode 100644 index 0000000000..17c29a3ccf --- /dev/null +++ b/browser/components/screenshots/tests/browser/test-selectionAPI-page.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Screenshots</title> +</head> +<body> + <p id="selection" style="color: white;width: fit-content;">hello world</p> +</body> +</html> diff --git a/browser/components/search/SearchSERPTelemetry.sys.mjs b/browser/components/search/SearchSERPTelemetry.sys.mjs index 2a9ed88db1..dc261f1300 100644 --- a/browser/components/search/SearchSERPTelemetry.sys.mjs +++ b/browser/components/search/SearchSERPTelemetry.sys.mjs @@ -70,13 +70,6 @@ ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { }); }); -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "serpEventsEnabled", - "browser.search.serpEventTelemetry.enabled", - true -); - const CATEGORIZATION_PREF = "browser.search.serpEventTelemetryCategorization.enabled"; @@ -500,11 +493,7 @@ class TelemetryHandler { // from something that happened in content. We keep this separate from // source because legacy telemetry should not change its reporting. let inContentSource; - if ( - lazy.serpEventsEnabled && - info.hasComponents && - this.#browserContentSourceMap.has(browser) - ) { + if (info.hasComponents && this.#browserContentSourceMap.has(browser)) { inContentSource = this.#browserContentSourceMap.get(browser); this.#browserContentSourceMap.delete(browser); } @@ -517,7 +506,7 @@ class TelemetryHandler { } let impressionId; - if (lazy.serpEventsEnabled && info.hasComponents) { + if (info.hasComponents) { // The UUID generated by Services.uuid contains leading and trailing braces. // Need to trim them first. impressionId = Services.uuid.generateUUID().toString().slice(1, -1); @@ -535,7 +524,7 @@ class TelemetryHandler { let item = this._browserInfoByURL.get(urlKey); let impressionInfo; - if (lazy.serpEventsEnabled && info.hasComponents) { + if (info.hasComponents) { let partnerCode = ""; if (info.code != "none" && info.code != null) { partnerCode = info.code; @@ -547,6 +536,7 @@ class TelemetryHandler { source: inContentSource ?? source, isShoppingPage: info.isShoppingPage, isPrivate: lazy.PrivateBrowsingUtils.isBrowserPrivate(browser), + isSignedIn: info.isSignedIn, }; } @@ -560,6 +550,8 @@ class TelemetryHandler { searchBoxSubmitted: false, categorizationInfo: null, adsClicked: 0, + adsHidden: 0, + adsLoaded: 0, adsVisible: 0, searchQuery: info.searchQuery, }); @@ -577,6 +569,8 @@ class TelemetryHandler { searchBoxSubmitted: false, categorizationInfo: null, adsClicked: 0, + adsHidden: 0, + adsLoaded: 0, adsVisible: 0, searchQuery: info.searchQuery, }), @@ -1056,19 +1050,27 @@ class TelemetryHandler { } let isShoppingPage = false; let hasComponents = false; - if (lazy.serpEventsEnabled) { - if (searchProviderInfo.shoppingTab?.regexp) { - isShoppingPage = searchProviderInfo.shoppingTab.regexp.test(url); - } - if (searchProviderInfo.components?.length) { - hasComponents = true; - } + let isSignedIn = false; + if (searchProviderInfo.shoppingTab?.regexp) { + isShoppingPage = searchProviderInfo.shoppingTab.regexp.test(url); + } + if (searchProviderInfo.components?.length) { + hasComponents = true; + } + if (searchProviderInfo.accountCookies) { + isSignedIn = searchProviderInfo.accountCookies.some(cookieObj => { + return Services.cookies + .getCookiesFromHost(cookieObj.host, {}) + .some(c => c.name == cookieObj.name); + }); } + return { provider: searchProviderInfo.telemetryId, type, code, isShoppingPage, + isSignedIn, hasComponents, searchQuery, isSPA, @@ -1341,10 +1343,6 @@ class ContentHandler { * The search provider info associated with the item. */ #maybeRecordSERPTelemetry(wrappedChannel, item, info) { - if (!lazy.serpEventsEnabled) { - return; - } - if (wrappedChannel._recordedClick) { lazy.logConsole.debug("Click already recorded."); return; @@ -1644,7 +1642,6 @@ class ContentHandler { } let telemetryState = item.browserTelemetryStateMap.get(browser); if ( - lazy.serpEventsEnabled && info.adImpressions && telemetryState && !telemetryState.adImpressionsReported @@ -1652,6 +1649,8 @@ class ContentHandler { for (let [componentType, data] of info.adImpressions.entries()) { // Not all ad impressions are sponsored. if (AD_COMPONENTS.includes(componentType)) { + telemetryState.adsHidden += data.adsHidden; + telemetryState.adsLoaded += data.adsLoaded; telemetryState.adsVisible += data.adsVisible; } @@ -1755,6 +1754,7 @@ class ContentHandler { shopping_tab_displayed: info.shoppingTabDisplayed, is_shopping_page: impressionInfo.isShoppingPage, is_private: impressionInfo.isPrivate, + is_signed_in: impressionInfo.isSignedIn, }); lazy.logConsole.debug(`Reported Impression:`, { impressionId, @@ -1805,6 +1805,8 @@ class ContentHandler { tagged: impressionInfo.tagged, is_shopping_page: impressionInfo.isShoppingPage, num_ads_clicked: telemetryState.adsClicked, + num_ads_hidden: telemetryState.adsHidden, + num_ads_loaded: telemetryState.adsLoaded, num_ads_visible: telemetryState.adsVisible, }); }; @@ -1844,6 +1846,10 @@ class ContentHandler { * @typedef {object} CategorizationExtraParams * @property {number} num_ads_clicked * The total number of ads clicked on a SERP. + * @property {number} num_ads_hidden + * The total number of ads hidden from the user when categorization occured. + * @property {number} num_ads_loaded + * The total number of ads loaded when categorization occured. * @property {number} num_ads_visible * The total number of ads visible to the user when categorization occured. */ diff --git a/browser/components/search/content/autocomplete-popup.js b/browser/components/search/content/autocomplete-popup.js index c5a33348ff..8736c683a7 100644 --- a/browser/components/search/content/autocomplete-popup.js +++ b/browser/components/search/content/autocomplete-popup.js @@ -7,6 +7,7 @@ // Wrap in a block to prevent leaking to window scope. { ChromeUtils.defineESModuleGetters(this, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", SearchOneOffs: "resource:///modules/SearchOneOffs.sys.mjs", }); @@ -194,7 +195,7 @@ let search = this.input.controller.getValueAt(this.selectedIndex); // open the search results according to the clicking subtlety - let where = whereToOpenLink(aEvent, false, true); + let where = BrowserUtils.whereToOpenLink(aEvent, false, true); let params = {}; // But open ctrl/cmd clicks on autocomplete items in a new background tab. diff --git a/browser/components/search/content/searchbar.js b/browser/components/search/content/searchbar.js index c872236472..e0fe7f41e3 100644 --- a/browser/components/search/content/searchbar.js +++ b/browser/components/search/content/searchbar.js @@ -12,6 +12,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", FormHistory: "resource://gre/modules/FormHistory.sys.mjs", SearchSuggestionController: "resource://gre/modules/SearchSuggestionController.sys.mjs", @@ -317,7 +318,7 @@ if (aEvent.button == 2) { return; } - where = whereToOpenLink(aEvent, false, true); + where = lazy.BrowserUtils.whereToOpenLink(aEvent, false, true); if ( newTabPref && !aEvent.altKey && @@ -885,12 +886,13 @@ goDoCommand("cmd_paste"); this.handleSearchCommand(event); break; - case clearHistoryItem: + case clearHistoryItem: { let param = this.textbox.getAttribute("autocompletesearchparam"); lazy.FormHistory.update({ op: "remove", fieldname: param }); this.textbox.value = ""; break; - default: + } + default: { let cmd = event.originalTarget.getAttribute("cmd"); if (cmd) { let controller = @@ -898,6 +900,7 @@ controller.doCommand(cmd); } break; + } } }); } diff --git a/browser/components/search/metrics.yaml b/browser/components/search/metrics.yaml index c7636b9d04..8f81eff34b 100644 --- a/browser/components/search/metrics.yaml +++ b/browser/components/search/metrics.yaml @@ -193,6 +193,11 @@ serp: description: Indicates if the page was loaded while in Private Browsing Mode. type: boolean + is_signed_in: + description: + Indicates if the page was loaded while the user is signed in to a + provider's account. + type: boolean engagement: type: event @@ -340,6 +345,7 @@ serp: - 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 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1892267 data_reviews: - https://bugzilla.mozilla.org/show_bug.cgi?id=1868476 data_sensitivity: @@ -423,6 +429,16 @@ serp: description: > Indicates if the page is a shopping page. type: boolean + num_ads_hidden: + description: > + Number of ads hidden on the page at the time of categorizing the + page. + type: quantity + num_ads_loaded: + description: > + Number of ads loaded on the page at the time of categorizing the + page. + type: quantity num_ads_visible: description: > Number of ads visible on the page at the time of categorizing the diff --git a/browser/components/search/schema/search-telemetry-v2-ui-schema.json b/browser/components/search/schema/search-telemetry-v2-ui-schema.json index 749063db72..5a6f87f336 100644 --- a/browser/components/search/schema/search-telemetry-v2-ui-schema.json +++ b/browser/components/search/schema/search-telemetry-v2-ui-schema.json @@ -11,12 +11,12 @@ "organicCodes", "followOnParamNames", "followOnCookies", - "ignoreLinkRegexps", "extraAdServersRegexps", "adServerAttributes", "components", - "nonAdsLinkRegexps", + "ignoreLinkRegexps", "nonAdsLinkQueryParamNames", + "nonAdsLinkRegexps", "shoppingTab", "domainExtraction", "isSPA", diff --git a/browser/components/search/test/browser/telemetry/browser.toml b/browser/components/search/test/browser/telemetry/browser.toml index 5e42a9187d..c8cb201324 100644 --- a/browser/components/search/test/browser/telemetry/browser.toml +++ b/browser/components/search/test/browser/telemetry/browser.toml @@ -6,9 +6,6 @@ prefs = ["browser.search.log=true"] ["browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js"] support-files = ["searchTelemetryDomainCategorizationReporting.html"] -["browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js"] -support-files = ["searchTelemetryAd.html"] - ["browser_search_telemetry_abandonment.js"] support-files = [ "searchTelemetry.html", @@ -175,6 +172,9 @@ support-files = [ ["browser_search_telemetry_shopping.js"] support-files = ["searchTelemetryAd_shopping.html"] +["browser_search_telemetry_signed_in_to_account.js"] +support-files = ["searchTelemetry.html"] + ["browser_search_telemetry_sources.js"] support-files = ["searchTelemetry.html", "searchTelemetryAd.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 8e9db64fae..d6ff3e2ed9 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 @@ -162,6 +162,8 @@ add_task(async function test_enable_experiment_when_pref_is_not_enabled() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); diff --git a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js deleted file mode 100644 index 096178499b..0000000000 --- a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js +++ /dev/null @@ -1,167 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - * http://creativecommons.org/publicdomain/zero/1.0/ */ - -// Test to verify we can toggle the Glean SERP event telemetry feature via a -// Nimbus variable. - -const lazy = {}; - -const TELEMETRY_PREF = "browser.search.serpEventTelemetry.enabled"; - -ChromeUtils.defineESModuleGetters(lazy, { - ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", - ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", -}); - -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "serpEventsEnabled", - TELEMETRY_PREF, - false -); - -const TEST_PROVIDER_INFO = [ - { - telemetryId: "example", - searchPageRegexp: - /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/, - queryParamNames: ["s"], - codeParamName: "abc", - taggedCodes: ["ff"], - followOnParamNames: ["a"], - extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], - components: [ - { - type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, - default: true, - }, - ], - }, -]; - -async function verifyEventsRecorded() { - resetTelemetry(); - - let tab = await BrowserTestUtils.openNewForegroundTab( - gBrowser, - getSERPUrl("searchTelemetryAd.html") - ); - await waitForPageWithAdImpressions(); - - assertSERPTelemetry([ - { - impression: { - provider: "example", - tagged: "true", - partner_code: "ff", - source: "unknown", - is_shopping_page: "false", - is_private: "false", - shopping_tab_displayed: "false", - }, - adImpressions: [ - { - component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, - ads_loaded: "2", - ads_visible: "2", - ads_hidden: "0", - }, - ], - }, - ]); - - BrowserTestUtils.removeTab(tab); - - assertSERPTelemetry([ - { - impression: { - provider: "example", - tagged: "true", - partner_code: "ff", - source: "unknown", - is_shopping_page: "false", - is_private: "false", - shopping_tab_displayed: "false", - }, - adImpressions: [ - { - component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, - ads_loaded: "2", - ads_visible: "2", - ads_hidden: "0", - }, - ], - abandonment: { - reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, - }, - }, - ]); -} - -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; - - registerCleanupFunction(async () => { - SearchSERPTelemetry.overrideSearchTelemetryForTests(); - Services.telemetry.canRecordExtended = oldCanRecord; - await SpecialPowers.popPrefEnv(); - resetTelemetry(); - }); -}); - -add_task(async function test_enable_experiment_when_pref_is_not_enabled() { - let prefBranch = Services.prefs.getDefaultBranch(""); - let originalPrefValue = prefBranch.getBoolPref(TELEMETRY_PREF); - - // Ensure the build being tested has the preference value as false. - // Changing the preference in the test must be done on the default branch - // because in the telemetry code, we're referencing the preference directly - // instead of through NimbusFeatures. Enrolling in an experiment will change - // the default branch, and not overwrite the user branch. - prefBranch.setBoolPref(TELEMETRY_PREF, false); - - Assert.equal( - lazy.serpEventsEnabled, - false, - "serpEventsEnabled should be false when not enrolled in experiment." - ); - - await lazy.ExperimentAPI.ready(); - - let doExperimentCleanup = await lazy.ExperimentFakes.enrollWithFeatureConfig( - { - featureId: NimbusFeatures.search.featureId, - value: { - serpEventTelemetryEnabled: true, - }, - }, - { isRollout: true } - ); - - Assert.equal( - lazy.serpEventsEnabled, - true, - "serpEventsEnabled should be true when enrolled in experiment." - ); - - // To ensure Nimbus set "browser.search.serpEventTelemetry.enabled" to true, - // we test that an impression, ad_impression and abandonment event are - // recorded correctly. - await verifyEventsRecorded(); - - await doExperimentCleanup(); - - Assert.equal( - lazy.serpEventsEnabled, - false, - "serpEventsEnabled should be false after experiment." - ); - - // Clean up. - prefBranch.setBoolPref(TELEMETRY_PREF, originalPrefValue); -}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js index 0c1d8b8234..799c5018e9 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js @@ -2,7 +2,7 @@ * http://creativecommons.org/publicdomain/zero/1.0/ */ /* - * Tests for the Glean SERP abandonment event + * Tests for the Glean SERP abandonment event. */ "use strict"; @@ -32,9 +32,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -65,6 +62,7 @@ add_task(async function test_tab_close() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, @@ -99,6 +97,7 @@ add_task(async function test_window_close() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.WINDOW_CLOSE, @@ -134,6 +133,7 @@ add_task(async function test_navigation_via_urlbar() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, @@ -181,6 +181,7 @@ add_task(async function test_navigation_via_back_button() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js index 5a09353ed6..dd7629f5f3 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js @@ -103,9 +103,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); // The tests evaluate whether or not ads are visible depending on whether // they are within the view of the window. To ensure the test results @@ -142,6 +139,7 @@ add_task(async function test_ad_impressions_with_one_carousel() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -186,6 +184,7 @@ add_task(async function test_ad_impressions_with_two_carousels() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -221,6 +220,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -272,6 +272,7 @@ add_task(async function test_ad_impressions_with_carousels_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -304,6 +305,7 @@ add_task(async function test_ad_impressions_with_hidden_carousels() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -338,6 +340,7 @@ add_task(async function test_ad_impressions_with_carousel_scrolled_left() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -372,6 +375,7 @@ add_task(async function test_ad_impressions_with_carousel_below_the_fold() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -404,6 +408,7 @@ add_task(async function test_ad_impressions_with_text_links() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -463,6 +468,7 @@ add_task(async function test_ad_visibility() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -495,6 +501,7 @@ add_task(async function test_impressions_without_ads() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -527,6 +534,7 @@ add_task(async function test_ad_impressions_with_cookie_banner() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_children.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_children.js index 65cd612a49..6f4f5d7ec1 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_children.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_children.js @@ -28,6 +28,7 @@ const IMPRESSION = { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }; const SERP_URL = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_parent.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_parent.js index 8471215840..e6e5f04cf9 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_parent.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component_skipCount_parent.js @@ -28,6 +28,7 @@ const IMPRESSION = { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }; const SERP_URL = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js index 9ecc4e8d92..a43fae7ca2 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js @@ -30,9 +30,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); 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 daccbf0c93..a9201beb22 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 @@ -115,6 +115,8 @@ add_task(async function test_load_serp_and_categorize() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); @@ -156,6 +158,8 @@ add_task(async function test_load_serp_and_categorize_and_click_organic() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); @@ -195,6 +199,8 @@ add_task(async function test_load_serp_and_categorize_and_click_sponsored() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "1", + num_ads_hidden: "0", + num_ads_loaded: "2", 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 9bd215f697..694900912d 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 @@ -179,6 +179,8 @@ add_task(async function test_download_after_failure() { provider: "example", tagged: "true", is_shopping_page: "false", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", num_ads_clicked: "0", }, 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 index 2375cad82a..f23859dbd5 100644 --- 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 @@ -87,13 +87,14 @@ add_task( assertSERPTelemetry([ { impression: { - shopping_tab_displayed: "true", provider: "example", - source: "unknown", tagged: "true", - is_private: "false", - is_shopping_page: "false", partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -134,6 +135,8 @@ add_task( tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "0", num_ads_visible: "0", }, ]); 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 7dbf605396..45d97c85e8 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 @@ -126,6 +126,8 @@ add_task(async function test_categorize_page_with_different_region() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", 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 3c439844d7..fba30ec4a0 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 @@ -136,6 +136,8 @@ add_task(async function test_categorization_reporting() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); @@ -242,6 +244,8 @@ add_task(async function test_reporting_limited_to_10_domains_of_each_kind() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "12", num_ads_visible: "12", }, ]); @@ -279,6 +283,8 @@ add_task(async function test_categorization_reporting_for_shopping_page() { tagged: "true", is_shopping_page: "true", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", 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 0e2d1c07fd..59a3c15ef9 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 @@ -139,6 +139,8 @@ add_task(async function test_categorize_serp_and_wait() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); @@ -184,6 +186,8 @@ add_task(async function test_categorize_serp_open_multiple_tabs() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }); } @@ -238,6 +242,8 @@ add_task(async function test_categorize_serp_close_tab_and_wait() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); @@ -292,6 +298,8 @@ add_task(async function test_categorize_serp_open_ad_and_wait() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "1", + num_ads_hidden: "0", + num_ads_loaded: "2", 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 43c520a8d0..b824ec9817 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 @@ -151,6 +151,8 @@ add_task(async function test_categorize_serp_and_sleep() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); @@ -209,6 +211,8 @@ add_task(async function test_categorize_serp_and_sleep_not_long_enough() { tagged: "true", is_shopping_page: "false", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }, ]); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js index 791e29a01f..7392c15396 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js @@ -89,9 +89,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -142,6 +139,7 @@ add_task(async function test_click_cached_page() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -173,6 +171,7 @@ add_task(async function test_click_cached_page() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js index 72e26639fb..13861f4b27 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js @@ -48,9 +48,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js index f94e6b0bd8..993568abe6 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js @@ -75,9 +75,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -113,6 +110,7 @@ add_task(async function test_click_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -144,6 +142,7 @@ add_task(async function test_click_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -192,6 +191,7 @@ add_task(async function test_click_shopping() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -223,6 +223,7 @@ add_task(async function test_click_shopping() { is_shopping_page: "true", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -275,6 +276,7 @@ add_task(async function test_click_related_search_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -306,6 +308,7 @@ add_task(async function test_click_related_search_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -361,6 +364,7 @@ add_task(async function test_click_redirect_search_in_newtab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -392,6 +396,7 @@ add_task(async function test_click_redirect_search_in_newtab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -454,6 +459,7 @@ add_task(async function test_content_source_reset() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -489,6 +495,7 @@ add_task(async function test_content_source_reset() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -520,6 +527,7 @@ add_task(async function test_content_source_reset() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -580,6 +588,7 @@ add_task(async function test_click_refinement_button() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -611,6 +620,7 @@ add_task(async function test_click_refinement_button() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_children.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_children.js index 4f5aaf9378..dce34e9443 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_children.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_children.js @@ -28,6 +28,7 @@ const IMPRESSION = { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }; const SELECTOR = ".arrow"; diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_parent.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_parent.js index 4e3c635b4c..6d509ba904 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_parent.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_eventListeners_parent.js @@ -29,6 +29,7 @@ const IMPRESSION = { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }; const SELECTOR = ".arrow"; diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_ignoreLinkRegexps.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_ignoreLinkRegexps.js index 10f2a2d836..86e9779d0b 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_ignoreLinkRegexps.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_ignoreLinkRegexps.js @@ -49,6 +49,7 @@ const IMPRESSION = { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }; const SERP_URL = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js index fbe6f4fc73..0e76c0c8a1 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js @@ -63,10 +63,7 @@ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.search.serpEventTelemetry.enabled", true], - ["dom.ipc.processCount.webIsolated", MAX_IPC], - ], + set: [["dom.ipc.processCount.webIsolated", MAX_IPC]], }); registerCleanupFunction(async () => { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_nonAdsLinkQueryParamNames.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_nonAdsLinkQueryParamNames.js index 93a6b7993e..b334aa5637 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_nonAdsLinkQueryParamNames.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_nonAdsLinkQueryParamNames.js @@ -76,6 +76,7 @@ add_task(async function test_click_absolute_url_in_query_param() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -101,6 +102,7 @@ add_task(async function test_click_absolute_url_in_query_param() { is_shopping_page: "true", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -151,6 +153,7 @@ add_task(async function test_click_relative_href_in_query_param() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { @@ -176,6 +179,7 @@ add_task(async function test_click_relative_href_in_query_param() { is_shopping_page: "true", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -226,6 +230,7 @@ add_task(async function test_click_irrelevant_href_in_query_param() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js index d351234d50..94e8ab24fe 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js @@ -31,10 +31,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - // Enable local telemetry recording for the duration of the tests. - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -71,6 +67,7 @@ add_task(async function test_click_non_ads_link() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -128,6 +125,7 @@ add_task(async function test_click_non_ad_with_no_ads() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js index 6d93707d68..da8c952191 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js @@ -5,7 +5,6 @@ * These tests load SERPs and check that query params that are changed either * by the browser or in the page after click are still properly recognized * as ads. - * */ "use strict"; @@ -50,9 +49,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -88,6 +84,7 @@ add_task(async function test_click_links() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -136,6 +133,7 @@ add_task(async function test_click_links() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -167,6 +165,7 @@ add_task(async function test_click_links() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -231,6 +230,7 @@ add_task(async function test_click_link_with_more_parameters() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -294,6 +294,7 @@ add_task(async function test_click_link_with_fewer_parameters() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -357,6 +358,7 @@ add_task(async function test_click_link_with_reordered_parameters() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js index 5d7f2ee408..7851cbb7e7 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js @@ -36,10 +36,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - // Enable local telemetry recording for the duration of the tests. - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -79,6 +75,7 @@ add_task(async function test_click_non_ads_link_redirected() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -139,6 +136,7 @@ add_task(async function test_click_non_ads_link_redirected_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -195,6 +193,7 @@ add_task(async function test_click_non_ads_link_redirect_non_top_level() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -248,6 +247,7 @@ add_task(async function test_multiple_redirects_non_ad_link() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -301,6 +301,7 @@ add_task(async function test_click_ad_link_redirected() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -349,6 +350,7 @@ add_task(async function test_click_ad_link_redirected_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js index 8f7f7f4e05..5af024222c 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js @@ -148,9 +148,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -189,6 +186,7 @@ add_task(async function test_click_second_ad_in_component() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -245,6 +243,7 @@ add_task(async function test_click_ads_link_modified() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -300,6 +299,7 @@ add_task(async function test_click_and_submit_incontent_searchbox() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -321,6 +321,7 @@ add_task(async function test_click_and_submit_incontent_searchbox() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, }, ]); @@ -358,6 +359,7 @@ add_task(async function test_click_autosuggest() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -375,6 +377,7 @@ add_task(async function test_click_autosuggest() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, }, ]); @@ -404,6 +407,7 @@ add_task(async function test_click_carousel_expand() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -464,6 +468,7 @@ add_task(async function test_click_link_with_special_characters_in_path() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -519,6 +524,7 @@ add_task(async function test_click_cookie_banner_accept() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -564,6 +570,7 @@ add_task(async function test_click_cookie_banner_reject() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -609,6 +616,7 @@ add_task(async function test_click_cookie_banner_more_options() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js index 4f943fe92d..8866f72579 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js @@ -30,10 +30,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - // Enable local telemetry recording for the duration of the tests. - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -83,6 +79,7 @@ add_task(async function load_serp_in_new_window_with_pref_and_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -148,6 +145,7 @@ add_task(async function load_serp_in_new_window_with_pref_and_click_organic() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -214,6 +212,7 @@ add_task(async function load_serp_in_new_window_with_context_menu() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -303,6 +302,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -322,6 +322,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js index ea7556c8f6..2c4494e202 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js @@ -31,10 +31,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - // Enable local telemetry recording for the duration of the tests. - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -82,6 +78,7 @@ add_task(async function load_2_pbm_serps_and_1_non_pbm_serp() { is_shopping_page: "false", is_private: "true", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, @@ -96,6 +93,7 @@ add_task(async function load_2_pbm_serps_and_1_non_pbm_serp() { is_shopping_page: "false", is_private: "true", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, @@ -118,6 +116,7 @@ add_task(async function load_2_pbm_serps_and_1_non_pbm_serp() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js index 5f2afcf6fc..a8a52ba794 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js @@ -86,7 +86,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. await SpecialPowers.pushPrefEnv({ set: [ - ["browser.search.serpEventTelemetry.enabled", true], // Set the IPC count to a small number so that we only have to open // one additional tab to reuse the same process. ["dom.ipc.processCount.webIsolated", 1], @@ -139,6 +138,7 @@ add_task(async function update_telemetry_tab_already_open() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -192,6 +192,7 @@ add_task(async function update_telemetry_tab_closed() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -246,6 +247,7 @@ add_task(async function update_telemetry_multiple_tabs() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -307,6 +309,7 @@ add_task(async function update_telemetry_multiple_processes_and_tabs() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js index e2352b53f4..b687f025fd 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js @@ -55,9 +55,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -83,6 +80,7 @@ async function loadSerpAndClickShoppingTab(page) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, adImpressions: [ { @@ -109,6 +107,7 @@ async function loadSerpAndClickShoppingTab(page) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "true", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_signed_in_to_account.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_signed_in_to_account.js new file mode 100644 index 0000000000..701016e5b0 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_signed_in_to_account.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test to verify that the SERP impression event correctly records whether the + * user is logged in to a provider's account at the time of SERP load. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/, + queryParamNames: ["s"], + accountCookies: [ + { + host: "accounts.google.com", + name: "SID", + }, + ], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function simulateGoogleAccountSignIn() { + // Manually set the cookie that is present when the client is signed in to a + // Google account. + Services.cookies.add( + "accounts.google.com", + "", + "SID", + "dummy_cookie_value", + false, + false, + false, + Date.now() + 1000 * 60 * 60, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); +} + +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; + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_not_signed_in_to_google_account() { + info("Loading SERP while not signed in to Google account."); + let url = getSERPUrl("searchTelemetry.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + is_signed_in: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); + +add_task(async function test_signed_in_to_google_account() { + simulateGoogleAccountSignIn(); + + info("Loading SERP while signed in to Google account."); + let url = getSERPUrl("searchTelemetry.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + is_signed_in: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); + Services.cookies.removeAll(); + resetTelemetry(); +}); + +add_task(async function test_toggle_google_account_signed_in_status() { + info("Loading SERP while not signed in to Google account."); + let url = getSERPUrl("searchTelemetry.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + is_signed_in: "false", + }, + }, + ]); + + resetTelemetry(); + + info("Signing in to Google account."); + simulateGoogleAccountSignIn(); + + info("Loading SERP after signing in to Google account."); + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + is_signed_in: "true", + }, + }, + ]); + + resetTelemetry(); + + info("Signing out of Google account."); + Services.cookies.removeAll(); + + info("Loading SERP after signing out of Google account."); + let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + is_signed_in: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); + resetTelemetry(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js index 7fa66a1adf..3cb246f21d 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js @@ -63,7 +63,6 @@ add_setup(async function () { ], // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. ["browser.urlbar.maxHistoricalSearchSuggestions", 0], - ["browser.search.serpEventTelemetry.enabled", true], ], }); // Enable local telemetry recording for the duration of the tests. @@ -148,6 +147,7 @@ async function track_ad_click( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js index a313c75ac7..fa596acac1 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js @@ -4,7 +4,6 @@ /* * Main tests for SearchSERPTelemetry - general engine visiting and link * clicking on about pages. - * */ "use strict"; @@ -61,7 +60,6 @@ add_setup(async function () { ], // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. ["browser.urlbar.maxHistoricalSearchSuggestions", 0], - ["browser.search.serpEventTelemetry.enabled", true], ], }); // Enable local telemetry recording for the duration of the tests. @@ -146,6 +144,7 @@ async function track_ad_click( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js index 0fd93da30f..97480b7d36 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js @@ -38,9 +38,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -77,6 +74,7 @@ add_task(async function test_simple_search_page_visit() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, @@ -120,6 +118,7 @@ add_task(async function test_simple_search_page_visit_telemetry() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, @@ -156,6 +155,7 @@ add_task(async function test_follow_on_visit() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, @@ -170,6 +170,7 @@ add_task(async function test_follow_on_visit() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, abandonment: { reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, @@ -205,6 +206,7 @@ add_task(async function test_track_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -247,6 +249,7 @@ add_task(async function test_track_ad_organic() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -294,6 +297,7 @@ add_task(async function test_track_ad_new_window() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -349,6 +353,7 @@ add_task(async function test_track_ad_pages_without_ads() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, }, { @@ -360,6 +365,7 @@ add_task(async function test_track_ad_pages_without_ads() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js index 11d2176563..1b7dee7f04 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js @@ -34,9 +34,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -82,6 +79,7 @@ async function track_ad_click(testOrganic) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -118,6 +116,7 @@ async function track_ad_click(testOrganic) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -164,6 +163,7 @@ async function track_ad_click(testOrganic) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -189,6 +189,7 @@ async function track_ad_click(testOrganic) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -228,6 +229,7 @@ async function track_ad_click(testOrganic) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -253,6 +255,7 @@ async function track_ad_click(testOrganic) { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -307,6 +310,7 @@ add_task(async function test_track_ad_click_with_location_change_other_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -350,6 +354,7 @@ add_task(async function test_track_ad_click_with_location_change_other_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js index 3c5e0a464e..02b716c423 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js @@ -2,7 +2,8 @@ * http://creativecommons.org/publicdomain/zero/1.0/ */ /* - * Tests for SearchSERPTelemetry associated with ad links found in data attributes. + * Tests for SearchSERPTelemetry associated with ad links found in data + * attributes. */ "use strict"; @@ -34,9 +35,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -76,6 +74,7 @@ add_task(async function test_track_ad_on_data_attributes() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -122,6 +121,7 @@ add_task(async function test_track_ad_on_data_attributes_and_hrefs() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -165,6 +165,7 @@ add_task(async function test_track_no_ad_on_data_attributes_and_hrefs() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, }, ]); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js index 069e13d339..b79f31b678 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js @@ -34,9 +34,6 @@ add_setup(async function () { // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); registerCleanupFunction(async () => { SearchSERPTelemetry.overrideSearchTelemetryForTests(); @@ -84,6 +81,7 @@ add_task(async function test_track_ad_on_DOMContentLoaded() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -126,6 +124,7 @@ add_task(async function test_track_ad_on_load_event() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js index 9bff667857..a7237506f6 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js @@ -43,9 +43,6 @@ const TEST_PROVIDER_INFO = [ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); - await SpecialPowers.pushPrefEnv({ - set: [["browser.search.serpEventTelemetry.enabled", true]], - }); // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; Services.telemetry.canRecordExtended = true; @@ -88,6 +85,7 @@ add_task(async function test_source_opened_in_new_tab_via_middle_click() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -113,6 +111,7 @@ add_task(async function test_source_opened_in_new_tab_via_middle_click() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -162,6 +161,7 @@ add_task(async function test_source_opened_in_new_tab_via_target_blank() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -187,6 +187,7 @@ add_task(async function test_source_opened_in_new_tab_via_target_blank() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -250,6 +251,7 @@ add_task(async function test_source_opened_in_new_tab_via_context_menu() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -275,6 +277,7 @@ add_task(async function test_source_opened_in_new_tab_via_context_menu() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -317,6 +320,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -343,6 +347,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -386,6 +391,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -412,6 +418,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -464,6 +471,7 @@ add_task(async function test_refinement_button_vs_opened_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -489,6 +497,7 @@ add_task(async function test_refinement_button_vs_opened_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js index 7ce681701a..58519d82ac 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js @@ -56,10 +56,7 @@ add_setup(async function () { SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); await waitForIdle(); await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.urlbar.suggest.searches", true], - ["browser.search.serpEventTelemetry.enabled", true], - ], + set: [["browser.urlbar.suggest.searches", true]], }); // Enable local telemetry recording for the duration of the tests. let oldCanRecord = Services.telemetry.canRecordExtended; @@ -130,6 +127,7 @@ add_task(async function test_search() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -172,6 +170,7 @@ add_task(async function test_reload() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -194,6 +193,7 @@ add_task(async function test_reload() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -233,6 +233,7 @@ add_task(async function test_reload() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -255,6 +256,7 @@ add_task(async function test_reload() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, engagements: [ { @@ -306,6 +308,7 @@ add_task(async function test_fresh_search() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -345,6 +348,7 @@ add_task(async function test_click_ad() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, engagements: [ { @@ -394,6 +398,7 @@ add_task(async function test_go_back() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, engagements: [ { @@ -419,6 +424,7 @@ add_task(async function test_go_back() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -459,6 +465,7 @@ add_task(async function test_go_back() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, engagements: [ { @@ -484,6 +491,7 @@ add_task(async function test_go_back() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, engagements: [ { @@ -538,6 +546,7 @@ add_task(async function test_fresh_search_with_urlbar_persisted() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -577,6 +586,7 @@ add_task(async function test_fresh_search_with_urlbar_persisted() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -599,6 +609,7 @@ add_task(async function test_fresh_search_with_urlbar_persisted() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -640,6 +651,7 @@ add_task(async function test_fresh_search_with_urlbar_persisted() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -662,6 +674,7 @@ add_task(async function test_fresh_search_with_urlbar_persisted() { shopping_tab_displayed: "false", is_shopping_page: "false", is_private: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js index cb9e123622..1d31fc4456 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js @@ -4,7 +4,6 @@ /* * Main tests for SearchSERPTelemetry - general engine visiting and * link clicking with Web Extensions. - * */ "use strict"; @@ -42,7 +41,6 @@ add_setup(async function () { ], // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. ["browser.urlbar.maxHistoricalSearchSuggestions", 0], - ["browser.search.serpEventTelemetry.enabled", true], ], }); // Enable local telemetry recording for the duration of the tests. @@ -127,6 +125,7 @@ async function track_ad_click( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js index 39270c7e9f..f0d731c577 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js @@ -52,6 +52,7 @@ add_task(async function test_content_process_type_search_click_suggestion() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -81,6 +82,7 @@ add_task(async function test_content_process_type_search_click_suggestion() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -125,6 +127,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -154,6 +157,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -197,6 +201,7 @@ add_task(async function test_content_process_engagement() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -246,6 +251,7 @@ add_task(async function test_content_process_engagement_that_changes_page() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -271,6 +277,7 @@ add_task(async function test_content_process_engagement_that_changes_page() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -319,6 +326,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -344,6 +352,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -369,6 +378,7 @@ add_task( is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -417,6 +427,7 @@ add_task(async function test_unload_listeners_single_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -476,6 +487,7 @@ add_task(async function test_unload_listeners_multi_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -501,6 +513,7 @@ add_task(async function test_unload_listeners_multi_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js index 1e44957daa..d3b064ffbf 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js @@ -55,6 +55,7 @@ add_task(async function test_load_serps_and_click_organic() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -80,6 +81,7 @@ add_task(async function test_load_serps_and_click_organic() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -141,6 +143,7 @@ add_task(async function test_load_serps_and_click_ads() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -166,6 +169,7 @@ add_task(async function test_load_serps_and_click_ads() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -223,6 +227,7 @@ add_task(async function test_load_serps_and_click_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -248,6 +253,7 @@ add_task(async function test_load_serps_and_click_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -273,6 +279,7 @@ add_task(async function test_load_serps_and_click_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -292,6 +299,7 @@ add_task(async function test_load_serps_and_click_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -353,6 +361,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -379,6 +388,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -404,6 +414,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -426,6 +437,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -448,6 +460,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -467,6 +480,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -489,6 +503,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -511,6 +526,7 @@ add_task(async function test_load_pages_tabhistory() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js index 478a995e97..b5e54eb6bc 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js @@ -85,6 +85,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -111,6 +112,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -137,6 +139,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -164,6 +167,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -184,6 +188,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -210,6 +215,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -229,6 +235,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -249,6 +256,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [], }, @@ -262,6 +270,7 @@ add_task(async function test_load_serps_and_click_related_searches() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [], }, @@ -317,6 +326,7 @@ add_task(async function test_different_sources_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -337,6 +347,7 @@ add_task(async function test_different_sources_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -363,6 +374,7 @@ add_task(async function test_different_sources_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -386,6 +398,7 @@ add_task(async function test_different_sources_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -450,6 +463,7 @@ add_task(async function test_different_sources_click_redirect_ad_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -470,6 +484,7 @@ add_task(async function test_different_sources_click_redirect_ad_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -496,6 +511,7 @@ add_task(async function test_different_sources_click_redirect_ad_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -519,6 +535,7 @@ add_task(async function test_different_sources_click_redirect_ad_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -574,6 +591,7 @@ add_task(async function test_update_query_params_after_search() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -600,6 +618,7 @@ add_task(async function test_update_query_params_after_search() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -620,6 +639,7 @@ add_task(async function test_update_query_params_after_search() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -673,6 +693,7 @@ add_task(async function test_update_query_params() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -699,6 +720,7 @@ add_task(async function test_update_query_params() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -719,6 +741,7 @@ add_task(async function test_update_query_params() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -779,6 +802,7 @@ add_task(async function test_update_query_params_multiple_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -805,6 +829,7 @@ add_task(async function test_update_query_params_multiple_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -825,6 +850,7 @@ add_task(async function test_update_query_params_multiple_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -851,6 +877,7 @@ add_task(async function test_update_query_params_multiple_related() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js index 4f85c6cfa1..360e531c14 100644 --- a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js @@ -37,6 +37,7 @@ add_task(async function test_load_serp() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -95,6 +96,7 @@ add_task(async function test_load_serp_and_push_unrelated_state() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -134,6 +136,7 @@ add_task(async function test_load_serp_and_load_non_serp_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -176,6 +179,7 @@ add_task(async function test_load_serp_and_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -207,6 +211,7 @@ add_task(async function test_load_serp_and_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -251,6 +256,7 @@ add_task(async function test_load_serp_and_click_redirect_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -294,6 +300,7 @@ add_task(async function test_load_serp_and_click_redirect_ad_in_new_tab() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -338,6 +345,7 @@ add_task(async function test_load_serp_click_a_related_search() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -363,6 +371,7 @@ add_task(async function test_load_serp_click_a_related_search() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -401,6 +410,7 @@ add_task(async function test_load_serp_click_a_related_search_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -426,6 +436,7 @@ add_task(async function test_load_serp_click_a_related_search_click_ad() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -470,6 +481,7 @@ add_task(async function test_load_serp_click_non_serp_tab_click_all() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -509,6 +521,7 @@ add_task(async function test_load_serp_click_non_serp_tab_click_all() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -534,6 +547,7 @@ add_task(async function test_load_serp_click_non_serp_tab_click_all() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -576,6 +590,7 @@ add_task(async function test_load_serp_and_use_back_and_forward() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, engagements: [ { @@ -601,6 +616,7 @@ add_task(async function test_load_serp_and_use_back_and_forward() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -623,6 +639,7 @@ add_task(async function test_load_serp_and_use_back_and_forward() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { @@ -645,6 +662,7 @@ add_task(async function test_load_serp_and_use_back_and_forward() { is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", + is_signed_in: "false", }, adImpressions: [ { diff --git a/browser/components/search/test/marionette/telemetry/test_ping_submitted.py b/browser/components/search/test/marionette/telemetry/test_ping_submitted.py index cefe2d72d1..0ad2095b30 100644 --- a/browser/components/search/test/marionette/telemetry/test_ping_submitted.py +++ b/browser/components/search/test/marionette/telemetry/test_ping_submitted.py @@ -63,6 +63,8 @@ class TestPingSubmitted(MarionetteTestCase): provider: "example", tagged: "true", num_ads_clicked: "0", + num_ads_hidden: "0", + num_ads_loaded: "2", num_ads_visible: "2", }); """ diff --git a/browser/components/search/test/unit/test_ui_schemas_valid.js b/browser/components/search/test/unit/test_ui_schemas_valid.js index 3396f38238..17fb802085 100644 --- a/browser/components/search/test/unit/test_ui_schemas_valid.js +++ b/browser/components/search/test/unit/test_ui_schemas_valid.js @@ -7,7 +7,7 @@ let schemas = [ ["search-telemetry-v2-schema.json", "search-telemetry-v2-ui-schema.json"], ]; -async function checkUISchemaValid(configSchema, uiSchema) { +function checkUISchemaValid(configSchema, uiSchema) { for (let key of Object.keys(configSchema.properties)) { Assert.ok( uiSchema["ui:order"].includes(key), @@ -26,6 +26,6 @@ add_task(async function test_ui_schemas_valid() { PathUtils.join(do_get_cwd().path, uiSchema) ); - await checkUISchemaValid(schemaData, uiSchemaData); + checkUISchemaValid(schemaData, uiSchemaData); } }); diff --git a/browser/components/search/test/unit/test_urlTelemetry_generic.js b/browser/components/search/test/unit/test_urlTelemetry_generic.js index e967002421..556e167681 100644 --- a/browser/components/search/test/unit/test_urlTelemetry_generic.js +++ b/browser/components/search/test/unit/test_urlTelemetry_generic.js @@ -7,7 +7,6 @@ ChromeUtils.defineESModuleGetters(this, { PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs", - SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", sinon: "resource://testing-common/Sinon.sys.mjs", }); @@ -64,10 +63,11 @@ const TESTS = [ provider: "example", tagged: "true", partner_code: "ff", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -81,10 +81,11 @@ const TESTS = [ provider: "example", tagged: "true", partner_code: "ff", + source: "unknown", is_shopping_page: "true", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -98,10 +99,11 @@ const TESTS = [ provider: "example", tagged: "true", partner_code: "tb", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -115,10 +117,11 @@ const TESTS = [ provider: "example", tagged: "false", partner_code: "foo", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -132,10 +135,11 @@ const TESTS = [ provider: "example", tagged: "false", partner_code: "other", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -149,10 +153,11 @@ const TESTS = [ provider: "example", tagged: "false", partner_code: "other", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -166,10 +171,11 @@ const TESTS = [ provider: "example", tagged: "false", partner_code: "", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -183,10 +189,11 @@ const TESTS = [ provider: "example", tagged: "false", partner_code: "", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, { @@ -200,10 +207,11 @@ const TESTS = [ provider: "example2", tagged: "false", partner_code: "", + source: "unknown", is_shopping_page: "false", is_private: "false", shopping_tab_displayed: "false", - source: "unknown", + is_signed_in: "false", }, }, ]; @@ -259,10 +267,6 @@ async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) { do_get_profile(); add_task(async function setup() { - Services.prefs.setBoolPref( - SearchUtils.BROWSER_SEARCH_PREF + "serpEventTelemetry.enabled", - true - ); Services.fog.initializeFOG(); await SearchSERPTelemetry.init(); SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); diff --git a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs index d0627180f0..ebb2a66a53 100644 --- a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs +++ b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs @@ -183,9 +183,9 @@ export var RecentlyClosedTabsAndWindowsMenuUtils = { * The command event when the user clicks the restore all menu item */ onRestoreAllWindowsCommand() { - const count = lazy.SessionStore.getClosedWindowCount(); - for (let index = 0; index < count; index++) { - lazy.SessionStore.undoCloseWindow(index); + const closedData = lazy.SessionStore.getClosedWindowData(); + for (const { closedId } of closedData) { + lazy.SessionStore.undoCloseById(closedId); } }, diff --git a/browser/components/sessionstore/SessionFile.sys.mjs b/browser/components/sessionstore/SessionFile.sys.mjs index 077529d739..a44a662e07 100644 --- a/browser/components/sessionstore/SessionFile.sys.mjs +++ b/browser/components/sessionstore/SessionFile.sys.mjs @@ -18,6 +18,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", RunState: "resource:///modules/sessionstore/RunState.sys.mjs", SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs", @@ -234,7 +235,7 @@ var SessionFileInternal = { // 1546847. Just in case there are problems in the format of // the parsed data, continue on. Favicons might be broken, but // the session will at least be recovered - console.error(e); + lazy.sessionStoreLogger.error(e); } } @@ -247,7 +248,7 @@ var SessionFileInternal = { ) ) { // Skip sessionstore files that we don't understand. - console.error( + lazy.sessionStoreLogger.warn( "Cannot extract data from Session Restore file ", path, ". Wrong format/version: " + JSON.stringify(parsed.version) + "." @@ -289,6 +290,7 @@ var SessionFileInternal = { Services.telemetry .getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS") .add(Date.now() - startMs); + lazy.sessionStoreLogger.debug(`Successful file read of ${key} file`); break; } catch (ex) { if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { @@ -304,13 +306,20 @@ var SessionFileInternal = { loadfail_reason: "File doesn't exist.", } ); + // A file not existing can be normal and expected. + lazy.sessionStoreLogger.debug( + `Can't read session file which doesn't exist: ${key}` + ); } else if ( DOMException.isInstance(ex) && ex.name == "NotAllowedError" ) { // The file might be inaccessible due to wrong permissions // or similar failures. We'll just count it as "corrupted". - console.error("Could not read session file ", ex); + lazy.sessionStoreLogger.error( + `NotAllowedError when reading session file: ${key}`, + ex + ); corrupted = true; Services.telemetry.recordEvent( "session_restore", @@ -324,7 +333,7 @@ var SessionFileInternal = { } ); } else if (ex instanceof SyntaxError) { - console.error( + lazy.sessionStoreLogger.error( "Corrupt session file (invalid JSON found) ", ex, ex.stack @@ -385,6 +394,9 @@ var SessionFileInternal = { if (!result) { // If everything fails, start with an empty session. + lazy.sessionStoreLogger.warn( + "No readable session files found to restore, starting with empty session" + ); result = { origin: "empty", source: "", diff --git a/browser/components/sessionstore/SessionLogger.sys.mjs b/browser/components/sessionstore/SessionLogger.sys.mjs new file mode 100644 index 0000000000..a7c99f911e --- /dev/null +++ b/browser/components/sessionstore/SessionLogger.sys.mjs @@ -0,0 +1,87 @@ +/* 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 { LogManager } from "resource://gre/modules/LogManager.sys.mjs"; +// See Bug 1889052 +// eslint-disable-next-line mozilla/use-console-createInstance +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + cancelIdleCallback: "resource://gre/modules/Timer.sys.mjs", + requestIdleCallback: "resource://gre/modules/Timer.sys.mjs", +}); + +const loggerNames = ["SessionStore"]; + +export const sessionStoreLogger = Log.repository.getLogger("SessionStore"); +sessionStoreLogger.manageLevelFromPref("browser.sessionstore.loglevel"); + +class SessionLogManager extends LogManager { + #idleCallbackId = null; + #observers = new Set(); + + QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]); + + constructor(options = {}) { + super(options); + + Services.obs.addObserver(this, "sessionstore-windows-restored"); + this.#observers.add("sessionstore-windows-restored"); + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "SessionLogManager: finalize and flush any logs to disk", + () => { + return this.stop(); + } + ); + } + + async stop() { + if (this.#observers.has("sessionstore-windows-restored")) { + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + this.#observers.delete("sessionstore-windows-restored"); + } + await this.requestLogFlush(true); + this.finalize(); + } + + observe(subject, topic, _) { + switch (topic) { + case "sessionstore-windows-restored": + // this represents the moment session restore is nominally complete + // and is a good time to ensure any log messages are flushed to disk + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + this.#observers.delete("sessionstore-windows-restored"); + this.requestLogFlush(); + break; + } + } + + async requestLogFlush(immediate = false) { + if (this.#idleCallbackId && !immediate) { + return; + } + if (this.#idleCallbackId) { + lazy.cancelIdleCallback(this.#idleCallbackId); + this.#idleCallbackId = null; + } + if (!immediate) { + await new Promise(resolve => { + this.#idleCallbackId = lazy.requestIdleCallback(resolve); + }); + this.#idleCallbackId = null; + } + await this.resetFileLog(); + } +} + +export const logManager = new SessionLogManager({ + prefRoot: "browser.sessionstore.", + logNames: loggerNames, + logFilePrefix: "sessionrestore", + logFileSubDirectoryEntries: ["sessionstore-logs"], + testTopicPrefix: "sessionrestore:log-manager:", +}); diff --git a/browser/components/sessionstore/SessionStartup.sys.mjs b/browser/components/sessionstore/SessionStartup.sys.mjs index 0d017ac035..72df4316e9 100644 --- a/browser/components/sessionstore/SessionStartup.sys.mjs +++ b/browser/components/sessionstore/SessionStartup.sys.mjs @@ -38,6 +38,7 @@ ChromeUtils.defineESModuleGetters(lazy, { SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", StartupPerformance: "resource:///modules/sessionstore/StartupPerformance.sys.mjs", + sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", }); const STATE_RUNNING_STR = "running"; @@ -50,32 +51,7 @@ const TYPE_DEFER_SESSION = 3; // 'browser.startup.page' preference value to resume the previous session. const BROWSER_STARTUP_RESUME_SESSION = 3; -function warning(msg, exception) { - let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( - Ci.nsIScriptError - ); - consoleMsg.init( - msg, - exception.fileName, - null, - exception.lineNumber, - 0, - Ci.nsIScriptError.warningFlag, - "component javascript" - ); - Services.console.logMessage(consoleMsg); -} - -var gOnceInitializedDeferred = (function () { - let deferred = {}; - - deferred.promise = new Promise((resolve, reject) => { - deferred.resolve = resolve; - deferred.reject = reject; - }); - - return deferred; -})(); +var gOnceInitializedDeferred = Promise.withResolvers(); /* :::::::: The Service ::::::::::::::: */ @@ -119,6 +95,7 @@ export var SessionStartup = { "browser.sessionstore.resuming_after_os_restart" ) ) { + lazy.sessionStoreLogger.debug("resuming_after_os_restart"); if (!Services.appinfo.restartedByOS) { // We had set resume_session_once in order to resume after an OS restart, // but we aren't automatically started by the OS (or else appinfo.restartedByOS @@ -136,8 +113,17 @@ export var SessionStartup = { } lazy.SessionFile.read().then( - this._onSessionFileRead.bind(this), - console.error + result => { + lazy.sessionStoreLogger.debug( + `Completed SessionFile.read() with result.origin: ${result.origin}` + ); + return this._onSessionFileRead(result); + }, + err => { + // SessionFile.read catches most expected failures, + // so a promise rejection here should be logged as an error + lazy.sessionStoreLogger.error("Failure from _onSessionFileRead", err); + } ); }, @@ -174,12 +160,18 @@ export var SessionStartup = { if (stateString != source) { // The session has been modified by an add-on, reparse. + lazy.sessionStoreLogger.debug( + "After sessionstore-state-read, session has been modified" + ); try { this._initialState = JSON.parse(stateString); } catch (ex) { // That's not very good, an add-on has rewritten the initial // state to something that won't parse. - warning("Observer rewrote the state to something that won't parse", ex); + lazy.sessionStoreLogger.error( + "'sessionstore-state-read' observer rewrote the state to something that won't parse", + ex + ); } } else { // No need to reparse @@ -189,6 +181,7 @@ export var SessionStartup = { if (this._initialState == null) { // No valid session found. this._sessionType = this.NO_SESSION; + lazy.sessionStoreLogger.debug("No valid session found"); Services.obs.notifyObservers(null, "sessionstore-state-finalized"); gOnceInitializedDeferred.resolve(); return; @@ -204,14 +197,24 @@ export var SessionStartup = { }, 0) ); }, 0); + lazy.sessionStoreLogger.debug( + `initialState contains ${pinnedTabCount} pinned tabs` + ); Services.telemetry.scalarSetMaximum( "browser.engagement.max_concurrent_tab_pinned_count", pinnedTabCount ); }, 60000); + let isAutomaticRestoreEnabled = this.isAutomaticRestoreEnabled(); + lazy.sessionStoreLogger.debug( + `isAutomaticRestoreEnabled: ${isAutomaticRestoreEnabled}` + ); // If this is a normal restore then throw away any previous session. - if (!this.isAutomaticRestoreEnabled() && this._initialState) { + if (!isAutomaticRestoreEnabled && this._initialState) { + lazy.sessionStoreLogger.debug( + "Discarding previous session as we have initialState" + ); delete this._initialState.lastSessionState; } @@ -276,10 +279,14 @@ export var SessionStartup = { shutdown_reason: previousSessionCrashedReason, } ); + lazy.sessionStoreLogger.debug( + `Previous shutdown ok? ${this._previousSessionCrashed}, reason: ${previousSessionCrashedReason}` + ); Services.obs.addObserver(this, "sessionstore-windows-restored", true); if (this.sessionType == this.NO_SESSION) { + lazy.sessionStoreLogger.debug("Will restore no session"); this._initialState = null; // Reset the state. } else { Services.obs.addObserver(this, "browser:purge-session-history", true); @@ -299,6 +306,7 @@ export var SessionStartup = { switch (topic) { case "sessionstore-windows-restored": Services.obs.removeObserver(this, "sessionstore-windows-restored"); + lazy.sessionStoreLogger.debug(`sessionstore-windows-restored`); // Free _initialState after nsSessionStore is done with it. this._initialState = null; this._didRestore = true; diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs index 16137b8388..7bd262fe09 100644 --- a/browser/components/sessionstore/SessionStore.sys.mjs +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -169,12 +169,15 @@ ChromeUtils.defineESModuleGetters(lazy, { DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", HomePage: "resource:///modules/HomePage.sys.mjs", + sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", RunState: "resource:///modules/sessionstore/RunState.sys.mjs", SessionCookies: "resource:///modules/sessionstore/SessionCookies.sys.mjs", SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", SessionSaver: "resource:///modules/sessionstore/SessionSaver.sys.mjs", SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", + SessionStoreHelper: + "resource://gre/modules/sessionstore/SessionStoreHelper.sys.mjs", TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", TabCrashHandler: "resource:///modules/ContentCrashHandlers.sys.mjs", TabState: "resource:///modules/sessionstore/TabState.sys.mjs", @@ -205,6 +208,9 @@ var gResistFingerprintingEnabled = false; * @namespace SessionStore */ export var SessionStore = { + get logger() { + return SessionStoreInternal._log; + }, get promiseInitialized() { return SessionStoreInternal.promiseInitialized; }, @@ -1046,6 +1052,10 @@ var SessionStoreInternal = { Services.telemetry .getHistogramById("FX_SESSION_RESTORE_PRIVACY_LEVEL") .add(Services.prefs.getIntPref("browser.sessionstore.privacy_level")); + + this.promiseAllWindowsRestored.finally(() => () => { + this._log.debug("promiseAllWindowsRestored finalized"); + }); }, /** @@ -1055,10 +1065,13 @@ var SessionStoreInternal = { TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); let state; let ss = lazy.SessionStartup; - - if (ss.willRestore() || ss.sessionType == ss.DEFER_SESSION) { + let willRestore = ss.willRestore(); + if (willRestore || ss.sessionType == ss.DEFER_SESSION) { state = ss.state; } + this._log.debug( + `initSession willRestore: ${willRestore}, SessionStartup.sessionType: ${ss.sessionType}` + ); if (state) { try { @@ -1074,6 +1087,9 @@ var SessionStoreInternal = { } else { state = null; } + this._log.debug( + `initSession deferred restore with ${iniState.windows.length} initial windows, ${remainingState.windows.length} remaining windows` + ); if (remainingState.windows.length) { LastSession.setState(remainingState); @@ -1092,6 +1108,9 @@ var SessionStoreInternal = { if (restoreAsCrashed) { this._recentCrashes = ((state.session && state.session.recentCrashes) || 0) + 1; + this._log.debug( + `initSession, restoreAsCrashed, crashes: ${this._recentCrashes}` + ); // _needsRestorePage will record sessionrestore_interstitial, // including the specific reason we decided we needed to show @@ -1106,9 +1125,11 @@ var SessionStoreInternal = { lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, }; state = { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + this._log.debug("initSession, will show about:sessionrestore"); } else if ( this._hasSingleTabWithURL(state.windows, "about:welcomeback") ) { + this._log.debug("initSession, will show about:welcomeback"); Services.telemetry.keyedScalarAdd( "browser.engagement.sessionrestore_interstitial", "shown_only_about_welcomeback", @@ -1131,7 +1152,7 @@ var SessionStoreInternal = { "autorestore", 1 ); - + this._log.debug("initSession, will autorestore"); this._removeExplicitlyClosedTabs(state); } @@ -1159,7 +1180,7 @@ var SessionStoreInternal = { state?.windows?.forEach(win => delete win._maybeDontRestoreTabs); state?._closedWindows?.forEach(win => delete win._maybeDontRestoreTabs); } catch (ex) { - this._log.error("The session file is invalid: " + ex); + this._log.error("The session file is invalid: ", ex); } } @@ -1243,10 +1264,7 @@ var SessionStoreInternal = { gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); }); - this._log = console.createInstance({ - prefix: "SessionStore", - maxLogLevel: gDebuggingEnabled ? "Debug" : "Warn", - }); + this._log = lazy.sessionStoreLogger; this._max_tabs_undo = this._prefBranch.getIntPref( "sessionstore.max_tabs_undo" @@ -1363,16 +1381,11 @@ var SessionStoreInternal = { } break; case "browsing-context-did-set-embedder": - 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.top && aSubject.isContent) { + const permanentKey = aSubject.embedderElement?.permanentKey; + if (permanentKey) { + this.maybeRecreateSHistoryListener(permanentKey, aSubject); + } } break; case "browsing-context-discarded": @@ -1388,11 +1401,28 @@ var SessionStoreInternal = { } }, - getOrCreateSHistoryListener( - permanentKey, - browsingContext, - collectImmediately = false - ) { + getOrCreateSHistoryListener(permanentKey, browsingContext) { + if (!permanentKey || browsingContext !== browsingContext.top) { + return null; + } + + const listener = this._browserSHistoryListener.get(permanentKey); + if (listener) { + return listener; + } + + return this.createSHistoryListener(permanentKey, browsingContext, false); + }, + + maybeRecreateSHistoryListener(permanentKey, browsingContext) { + const listener = this._browserSHistoryListener.get(permanentKey); + if (!listener || listener._browserId != browsingContext.browserId) { + listener?.unregister(permanentKey); + this.createSHistoryListener(permanentKey, browsingContext, true); + } + }, + + createSHistoryListener(permanentKey, browsingContext, collectImmediately) { class SHistoryListener { constructor() { this.QueryInterface = ChromeUtils.generateQI([ @@ -1495,21 +1525,12 @@ var SessionStoreInternal = { } } - if (!permanentKey || browsingContext !== browsingContext.top) { - return null; - } - let sessionHistory = browsingContext.sessionHistory; if (!sessionHistory) { return null; } - let listener = this._browserSHistoryListener.get(permanentKey); - if (listener) { - return listener; - } - - listener = new SHistoryListener(); + const listener = new SHistoryListener(); sessionHistory.addSHistoryListener(listener); this._browserSHistoryListener.set(permanentKey, listener); @@ -1804,6 +1825,9 @@ var SessionStoreInternal = { lazy.SessionSaver.updateLastSaveTime(); if (isPrivateWindow) { + this._log.debug( + "initializeWindow, the window is private. Saving SessionStartup.state for possibly restoring later" + ); // We're starting with a single private window. Save the state we // actually wanted to restore so that we can do it later in case // the user opens another, non-private window. @@ -1901,7 +1925,7 @@ var SessionStoreInternal = { windows: [closedWindowState], }); - // These are our pinned tabs, which we should restore + // These are our pinned tabs and sidebar attributes, which we should restore if (appTabsState.windows.length) { newWindowState = appTabsState.windows[0]; delete newWindowState.__lastSessionWindowID; @@ -1961,6 +1985,9 @@ var SessionStoreInternal = { // Just call initializeWindow() directly if we're initialized already. if (this._sessionInitialized) { + this._log.debug( + "onBeforeBrowserWindowShown, session already initialized, initializing window" + ); this.initializeWindow(aWindow); return; } @@ -1996,6 +2023,9 @@ var SessionStoreInternal = { this._promiseReadyForInitialization .then(() => { if (aWindow.closed) { + this._log.debug( + "When _promiseReadyForInitialization resolved, the window was closed" + ); return; } @@ -2020,7 +2050,12 @@ var SessionStoreInternal = { this._deferredInitialized.resolve(); } }) - .catch(console.error); + .catch(ex => { + this._log.error( + "Exception when handling _promiseReadyForInitialization resolution:", + ex + ); + }); }, /** @@ -4526,12 +4561,16 @@ var SessionStoreInternal = { } let sidebarBox = aWindow.document.getElementById("sidebar-box"); - let sidebar = sidebarBox.getAttribute("sidebarcommand"); - if (sidebar && sidebarBox.getAttribute("checked") == "true") { - winData.sidebar = sidebar; - } else if (winData.sidebar) { - delete winData.sidebar; + let command = sidebarBox.getAttribute("sidebarcommand"); + if (command && sidebarBox.getAttribute("checked") == "true") { + winData.sidebar = { + command, + positionEnd: sidebarBox.getAttribute("positionend"), + }; + } else if (winData.sidebar?.command) { + delete winData.sidebar.command; } + let workspaceID = aWindow.getWorkspaceID(); if (workspaceID) { winData.workspaceID = workspaceID; @@ -4755,6 +4794,7 @@ var SessionStoreInternal = { let windowsOpened = []; for (let winData of root.windows) { if (!winData || !winData.tabs || !winData.tabs[0]) { + this._log.debug(`_openWindows, skipping window with no tabs data`); this._restoreCount--; continue; } @@ -4799,6 +4839,8 @@ var SessionStoreInternal = { let overwriteTabs = aOptions && aOptions.overwriteTabs; let firstWindow = aOptions && aOptions.firstWindow; + this.restoreSidebar(aWindow, winData.sidebar); + // initialize window if necessary if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { this.onLoad(aWindow); @@ -4812,6 +4854,7 @@ var SessionStoreInternal = { this._setWindowStateBusy(aWindow); if (winData.workspaceID) { + this._log.debug(`Moving window to workspace: ${winData.workspaceID}`); aWindow.moveToWorkspace(winData.workspaceID); } @@ -4864,12 +4907,18 @@ var SessionStoreInternal = { this._prefBranch.getBoolPref("sessionstore.restore_tabs_lazily") && this._restore_on_demand; + this._log.debug( + `restoreWindow, will restore ${winData.tabs.length} tabs, restoreTabsLazily: ${restoreTabsLazily}` + ); if (winData.tabs.length) { var tabs = tabbrowser.createTabsForSessionRestore( restoreTabsLazily, selectTab, winData.tabs ); + this._log.debug( + `restoreWindow, createTabsForSessionRestore returned {tabs.length} tabs` + ); } // Move the originally open tabs to the end. @@ -5091,6 +5140,7 @@ var SessionStoreInternal = { root = typeof aState == "string" ? JSON.parse(aState) : aState; } catch (ex) { // invalid state object - don't restore anything + this._log.debug(`restoreWindows failed to parse ${typeof aState} state`); this._log.error(ex); this._sendRestoreCompletedNotifications(); return; @@ -5109,9 +5159,13 @@ var SessionStoreInternal = { ); } } + this._log.debug(`Restored ${this._closedWindows.length} closed windows`); this._closedObjectsChanged = true; } + this._log.debug( + `restoreWindows will restore ${root.windows?.length} windows` + ); // We're done here if there are no windows. if (!root.windows || !root.windows.length) { this._sendRestoreCompletedNotifications(); @@ -5234,7 +5288,7 @@ var SessionStoreInternal = { let browser = tab.linkedBrowser; if (TAB_STATE_FOR_BROWSER.has(browser)) { - console.error("Must reset tab before calling restoreTab."); + this._log.warn("Must reset tab before calling restoreTab."); return; } @@ -5549,13 +5603,34 @@ var SessionStoreInternal = { "screenX" in aWinData ? +aWinData.screenX : NaN, "screenY" in aWinData ? +aWinData.screenY : NaN, aWinData.sizemode || "", - aWinData.sizemodeBeforeMinimized || "", - aWinData.sidebar || "" + aWinData.sizemodeBeforeMinimized || "" ); + this.restoreSidebar(aWindow, aWinData.sidebar); }, 0); }, /** + * @param aWindow + * Window reference + * @param aSidebar + * Object containing command (sidebarcommand/category) and + * positionEnd (reflecting the sidebar.position_start pref) + */ + restoreSidebar(aWindow, aSidebar) { + let sidebarBox = aWindow.document.getElementById("sidebar-box"); + if ( + aSidebar?.command && + (sidebarBox.getAttribute("sidebarcommand") != aSidebar.command || + !sidebarBox.getAttribute("checked")) + ) { + aWindow.SidebarController.showInitially(aSidebar.command); + if (aSidebar?.positionEnd) { + sidebarBox.setAttribute("positionend", ""); + } + } + }, + + /** * Restore a window's dimensions * @param aWidth * Window width in desktop pixels @@ -5569,8 +5644,6 @@ var SessionStoreInternal = { * Window size mode (eg: maximized) * @param aSizeModeBeforeMinimized * Window size mode before window got minimized (eg: maximized) - * @param aSidebar - * Sidebar command */ restoreDimensions: function ssi_restoreDimensions( aWindow, @@ -5579,8 +5652,7 @@ var SessionStoreInternal = { aLeft, aTop, aSizeMode, - aSizeModeBeforeMinimized, - aSidebar + aSizeModeBeforeMinimized ) { var win = aWindow; var _this = this; @@ -5722,14 +5794,6 @@ var SessionStoreInternal = { break; } } - let sidebarBox = aWindow.document.getElementById("sidebar-box"); - if ( - aSidebar && - (sidebarBox.getAttribute("sidebarcommand") != aSidebar || - !sidebarBox.getAttribute("checked")) - ) { - aWindow.SidebarUI.showInitially(aSidebar); - } // since resizing/moving a window brings it to the foreground, // we might want to re-focus the last focused window if (this.windowToFocus) { @@ -5972,6 +6036,11 @@ var SessionStoreInternal = { features.push("private"); } + this._log.debug( + `Opening window with features: ${features.join( + "," + )}, argString: ${argString}.` + ); var window = Services.ww.openWindow( null, AppConstants.BROWSER_CHROME_URL, @@ -6251,6 +6320,15 @@ var SessionStoreInternal = { if (PERSIST_SESSIONS) { newWindowState._closedTabs = Cu.cloneInto(window._closedTabs, {}); } + + // We want to preserve the sidebar if previously open in the window + if (window.sidebar?.command) { + newWindowState.sidebar = { + command: window.sidebar.command, + positionEnd: !!window.sidebar.positionEnd, + }; + } + for (let tIndex = 0; tIndex < window.tabs.length; ) { if (window.tabs[tIndex].pinned) { // Adjust window.selected @@ -6383,6 +6461,7 @@ var SessionStoreInternal = { // This was the last window restored at startup, notify observers. if (!this._browserSetState) { Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + this._log.debug(`All ${this._restoreCount} windows restored`); this._deferredAllWindowsRestored.resolve(); } else { // _browserSetState is used only by tests, and it uses an alternate @@ -6691,98 +6770,6 @@ var SessionStoreInternal = { return deferred; }, - /** - * Builds a single nsISessionStoreRestoreData tree for the provided |formdata| - * and |scroll| trees. - */ - buildRestoreData(formdata, scroll) { - function addFormEntries(root, fields, isXpath) { - for (let [key, value] of Object.entries(fields)) { - switch (typeof value) { - case "string": - root.addTextField(isXpath, key, value); - break; - case "boolean": - root.addCheckbox(isXpath, key, value); - break; - case "object": { - if (value === null) { - break; - } - if ( - value.hasOwnProperty("type") && - value.hasOwnProperty("fileList") - ) { - root.addFileList(isXpath, key, value.type, value.fileList); - break; - } - if ( - value.hasOwnProperty("selectedIndex") && - value.hasOwnProperty("value") - ) { - root.addSingleSelect( - isXpath, - key, - value.selectedIndex, - value.value - ); - break; - } - if ( - value.hasOwnProperty("value") && - value.hasOwnProperty("state") - ) { - root.addCustomElement(isXpath, key, value.value, value.state); - break; - } - if ( - key === "sessionData" && - ["about:sessionrestore", "about:welcomeback"].includes( - formdata.url - ) - ) { - root.addTextField(isXpath, key, JSON.stringify(value)); - break; - } - if (Array.isArray(value)) { - root.addMultipleSelect(isXpath, key, value); - break; - } - } - } - } - } - - let root = SessionStoreUtils.constructSessionStoreRestoreData(); - if (scroll?.hasOwnProperty("scroll")) { - root.scroll = scroll.scroll; - } - if (formdata?.hasOwnProperty("url")) { - root.url = formdata.url; - if (formdata.hasOwnProperty("innerHTML")) { - // eslint-disable-next-line no-unsanitized/property - root.innerHTML = formdata.innerHTML; - } - if (formdata.hasOwnProperty("xpath")) { - addFormEntries(root, formdata.xpath, /* isXpath */ true); - } - if (formdata.hasOwnProperty("id")) { - addFormEntries(root, formdata.id, /* isXpath */ false); - } - } - let childrenLength = Math.max( - scroll?.children?.length || 0, - formdata?.children?.length || 0 - ); - for (let i = 0; i < childrenLength; i++) { - root.addChild( - this.buildRestoreData(formdata?.children?.[i], scroll?.children?.[i]), - i - ); - } - return root; - }, - _waitForStateStop(browser, expectedURL = null) { const deferred = Promise.withResolvers(); @@ -6956,7 +6943,10 @@ var SessionStoreInternal = { if (!haveUserTypedValue && tabData.entries.length) { return SessionStoreUtils.initializeRestore( browser.browsingContext, - this.buildRestoreData(tabData.formdata, tabData.scroll) + lazy.SessionStoreHelper.buildRestoreData( + tabData.formdata, + tabData.scroll + ) ); } // Here, we need to load user data or about:blank instead. diff --git a/browser/components/sessionstore/SessionStoreFunctions.sys.mjs b/browser/components/sessionstore/SessionStoreFunctions.sys.mjs new file mode 100644 index 0000000000..978c1a79cd --- /dev/null +++ b/browser/components/sessionstore/SessionStoreFunctions.sys.mjs @@ -0,0 +1,93 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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 { SessionStore } from "resource:///modules/sessionstore/SessionStore.sys.mjs"; + +export class SessionStoreFunctions { + UpdateSessionStore( + aBrowser, + aBrowsingContext, + aPermanentKey, + aEpoch, + aCollectSHistory, + aData + ) { + return SessionStoreFuncInternal.updateSessionStore( + aBrowser, + aBrowsingContext, + aPermanentKey, + aEpoch, + aCollectSHistory, + aData + ); + } + + UpdateSessionStoreForStorage( + aBrowser, + aBrowsingContext, + aPermanentKey, + aEpoch, + aData + ) { + return SessionStoreFuncInternal.updateSessionStoreForStorage( + aBrowser, + aBrowsingContext, + aPermanentKey, + aEpoch, + aData + ); + } +} + +var SessionStoreFuncInternal = { + updateSessionStore: function SSF_updateSessionStore( + aBrowser, + aBrowsingContext, + aPermanentKey, + aEpoch, + aCollectSHistory, + aData + ) { + let { formdata, scroll } = aData; + + if (formdata) { + aData.formdata = formdata.toJSON(); + } + + if (scroll) { + aData.scroll = scroll.toJSON(); + } + + SessionStore.updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + { + data: aData, + epoch: aEpoch, + sHistoryNeeded: aCollectSHistory, + } + ); + }, + + updateSessionStoreForStorage: function SSF_updateSessionStoreForStorage( + aBrowser, + aBrowsingContext, + aPermanentKey, + aEpoch, + aData + ) { + SessionStore.updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + { data: { storage: aData }, epoch: aEpoch }, + true + ); + }, +}; + +SessionStoreFunctions.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsISessionStoreFunctions", +]); diff --git a/browser/components/sessionstore/components.conf b/browser/components/sessionstore/components.conf new file mode 100644 index 0000000000..2776c4dcb7 --- /dev/null +++ b/browser/components/sessionstore/components.conf @@ -0,0 +1,12 @@ +# 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/. + +Classes = [ + { + 'cid': '{45ce6b2d-ffc8-4051-bb41-37ceeeb19e94}', + 'contract_ids': ['@mozilla.org/toolkit/sessionstore-functions;1'], + 'esModule': 'resource:///modules/sessionstore/SessionStoreFunctions.sys.mjs', + 'constructor': 'SessionStoreFunctions', + }, +] diff --git a/browser/components/sessionstore/moz.build b/browser/components/sessionstore/moz.build index cd3a0ad6fc..97554aab31 100644 --- a/browser/components/sessionstore/moz.build +++ b/browser/components/sessionstore/moz.build @@ -16,10 +16,12 @@ EXTRA_JS_MODULES.sessionstore = [ "RunState.sys.mjs", "SessionCookies.sys.mjs", "SessionFile.sys.mjs", + "SessionLogger.sys.mjs", "SessionMigration.sys.mjs", "SessionSaver.sys.mjs", "SessionStartup.sys.mjs", "SessionStore.sys.mjs", + "SessionStoreFunctions.sys.mjs", "SessionWriter.sys.mjs", "StartupPerformance.sys.mjs", "TabAttributes.sys.mjs", @@ -28,6 +30,10 @@ EXTRA_JS_MODULES.sessionstore = [ "TabStateFlusher.sys.mjs", ] +XPCOM_MANIFESTS += [ + "components.conf", +] + TESTING_JS_MODULES += [ "test/SessionStoreTestUtils.sys.mjs", ] diff --git a/browser/components/sessionstore/test/marionette/manifest.toml b/browser/components/sessionstore/test/marionette/manifest.toml index 6b62bea84e..0ff186778a 100644 --- a/browser/components/sessionstore/test/marionette/manifest.toml +++ b/browser/components/sessionstore/test/marionette/manifest.toml @@ -9,6 +9,10 @@ tags = "local" ["test_restore_manually_with_pinned_tabs.py"] +["test_restore_sidebar_automatic.py"] + +["test_restore_sidebar.py"] + ["test_restore_windows_after_close_last_tabs.py"] skip-if = ["os == 'mac'"] diff --git a/browser/components/sessionstore/test/marionette/test_restore_sidebar.py b/browser/components/sessionstore/test/marionette/test_restore_sidebar.py new file mode 100644 index 0000000000..042b9f2b23 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_sidebar.py @@ -0,0 +1,110 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 0.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/0.0/. + +import os +import sys + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +def inline(title): + return "data:text/html;charset=utf-8,<html><head><title>{}</title></head><body></body></html>".format( + title + ) + + +class TestSessionRestore(SessionStoreTestCase): + """ + Test that the sidebar and its attributes are restored on reopening of window. + """ + + def setUp(self): + super(TestSessionRestore, self).setUp( + startup_page=1, + include_private=False, + restore_on_demand=True, + test_windows=set( + [ + ( + inline("lorem ipsom"), + inline("dolor"), + ), + ] + ), + ) + + def test_restore(self): + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Should have 1 window open.", + ) + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + window.SidebarController.show("viewHistorySidebar"); + let sidebarBox = window.document.getElementById("sidebar-box") + sidebarBox.style.width = "100px"; + """ + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + return !window.document.getElementById("sidebar-box").hidden; + """ + ), + True, + "Sidebar is open before window is closed.", + ) + + self.marionette.restart() + self.marionette.set_context("chrome") + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session have been restored.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + return !window.document.getElementById("sidebar-box").hidden; + """ + ), + True, + "Sidebar has been restored.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + return window.document.getElementById("sidebar-box").style.width; + """ + ), + "100px", + "Sidebar width been restored.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + }); + let state = SessionStore.getCurrentState(); + return state.windows[0].sidebar.command; + """ + ), + "viewHistorySidebar", + "Correct sidebar category has been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_sidebar_automatic.py b/browser/components/sessionstore/test/marionette/test_restore_sidebar_automatic.py new file mode 100644 index 0000000000..58a9b93b47 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_sidebar_automatic.py @@ -0,0 +1,110 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 0.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/0.0/. + +import os +import sys + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +def inline(title): + return "data:text/html;charset=utf-8,<html><head><title>{}</title></head><body></body></html>".format( + title + ) + + +class TestSessionRestore(SessionStoreTestCase): + """ + Test that the sidebar and its attributes are restored on reopening of window. + """ + + def setUp(self): + super(TestSessionRestore, self).setUp( + startup_page=3, + include_private=False, + restore_on_demand=False, + test_windows=set( + [ + ( + inline("lorem ipsom"), + inline("dolor"), + ), + ] + ), + ) + + def test_restore(self): + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Should have 1 window open.", + ) + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + window.SidebarController.show("viewHistorySidebar"); + let sidebarBox = window.document.getElementById("sidebar-box") + sidebarBox.style.width = "100px"; + """ + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + return !window.document.getElementById("sidebar-box").hidden; + """ + ), + True, + "Sidebar is open before window is closed.", + ) + + self.marionette.restart() + self.marionette.set_context("chrome") + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session have been restored.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + return !window.document.getElementById("sidebar-box").hidden; + """ + ), + True, + "Sidebar has been restored.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let window = BrowserWindowTracker.getTopWindow() + return window.document.getElementById("sidebar-box").style.width; + """ + ), + "100px", + "Sidebar width been restored.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + }); + let state = SessionStore.getCurrentState(); + return state.windows[0].sidebar.command; + """ + ), + "viewHistorySidebar", + "Correct sidebar category has been restored.", + ) diff --git a/browser/components/shell/Windows11LimitedAccessFeatures.cpp b/browser/components/shell/Windows11LimitedAccessFeatures.cpp new file mode 100644 index 0000000000..6bf20b6706 --- /dev/null +++ b/browser/components/shell/Windows11LimitedAccessFeatures.cpp @@ -0,0 +1,281 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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 exists so that LaunchModernSettingsDialogDefaultApps can be called + * without linking to libxul. + */ +#include "Windows11LimitedAccessFeatures.h" + +#include "mozilla/Logging.h" + +static mozilla::LazyLogModule sLog("Windows11LimitedAccessFeatures"); + +#define LAF_LOG(level, msg, ...) MOZ_LOG(sLog, level, (msg, ##__VA_ARGS__)) + +// MINGW32 is not supported for these features +// Fall back function defined in the #else +#ifndef __MINGW32__ + +# include "nsString.h" +# include "nsWindowsHelpers.h" + +# include "mozilla/Atomics.h" + +# include <wrl.h> +# include <inspectable.h> +# include <roapi.h> +# include <windows.services.store.h> +# include <windows.foundation.h> + +using namespace Microsoft::WRL; +using namespace Microsoft::WRL::Wrappers; +using namespace ABI::Windows; +using namespace ABI::Windows::Foundation; +using namespace ABI::Windows::ApplicationModel; + +using namespace mozilla; + +/** + * To unlock features, we need: + * a feature identifier + * a token, + * an attestation string + * a token + * + * The token is generated by Microsoft and must + * match the publisher id Microsoft thinks we have, for a particular + * feature. + * + * To get a token, find the right microsoft email address by doing + * a search on the web for the feature you want unlocked and reach + * out to the right people at Microsoft. + * + * The token is generated from Microsoft. + * The jumbled code in the attestation string is a publisher id and + * must match the code in the resources / .rc file for the identity, + * looking like this for non-MSIX builds: + * + * Identity LimitedAccessFeature {{ L"MozillaFirefox_pcsmm0jrprpb2" }} + * + * Broken down: + * Identity LimitedAccessFeature {{ L"PRODUCTNAME_PUBLISHERID" }} + * + * That is injected into our build in create_rc.py and is necessary + * to unlock the taskbar pinning feature / APIs from an unpackaged + * build. + * + * In the above, the token is generated from the publisher id (pcsmm0jrprpb2) + * and the product name (MozillaFirefox) + * + * All tokens listed here were provided to us by Microsoft. + * + * Below and in create_rc.py, we used this set: + * + * Token: "kRFiWpEK5uS6PMJZKmR7MQ==" + * Product Name: "MozillaFirefox" + * Publisher ID: "pcsmm0jrprpb2" + * + * Microsoft also provided these other tokens, which will will + * work if accompanied by the matching changes to create_rc.py: + + * ----- + * Token: "RGEhsYgKhmPLKyzkEHnMhQ==" + * Product Name: "FirefoxBeta" + * Publisher ID: "pcsmm0jrprpb2" + * + * ----- + * + * Token: "qbVzns/9kT+t15YbIwT4Jw==" + * Product Name: "FirefoxNightly" + * Publisher ID: "pcsmm0jrprpb2" + * + * To use those instead, you have to ensure that the LimitedAccessFeature + * generated in create_rc.py has the product name and publisher id + * matching the token used in this file. + * + * For non-packaged (non-MSIX) builds, any of the above sets will work. + * Just make sure the right (ProductName_PublisherID) value is in the + * generated resource data for the executable, and the matching +* (Token) and attestation string + * + * To get MSIX/packaged builds to work, the product name and publisher in + * the final manifest (searchfox.org/mozilla-central/search?q=APPX_PUBLISHER) + * should match the token in this file. For that case, the identity value + * in the resources does not matter. + * + * See here for Microsoft examples: +https://github.com/microsoft/Windows-classic-samples/tree/main/Samples/TaskbarManager/CppUnpackagedDesktopTaskbarPin + */ + +struct LimitedAccessFeatureInfo { + const char* debugName; + const WCHAR* feature; + const WCHAR* token; + const WCHAR* attestation; +}; + +static LimitedAccessFeatureInfo limitedAccessFeatureInfo[] = { + {// Win11LimitedAccessFeatureType::Taskbar + "Win11LimitedAccessFeatureType::Taskbar", + L"com.microsoft.windows.taskbar.pin", L"kRFiWpEK5uS6PMJZKmR7MQ==", + L"pcsmm0jrprpb2 has registered their use of " + L"com.microsoft.windows.taskbar.pin with Microsoft and agrees to the " + L"terms " + L"of use."}}; + +static_assert(mozilla::ArrayLength(limitedAccessFeatureInfo) == + kWin11LimitedAccessFeatureTypeCount); + +/** + Implementation of the Win11LimitedAccessFeaturesInterface. + */ +class Win11LimitedAccessFeatures : public Win11LimitedAccessFeaturesInterface { + public: + using AtomicState = Atomic<int, SequentiallyConsistent>; + + Result<bool, HRESULT> Unlock(Win11LimitedAccessFeatureType feature) override; + + private: + AtomicState& GetState(Win11LimitedAccessFeatureType feature); + Result<bool, HRESULT> UnlockImplementation( + Win11LimitedAccessFeatureType feature); + + /** + * Store the state as an atomic so that it can be safely accessed from + * different threads. + */ + static AtomicState mTaskbarState; + static AtomicState mDefaultState; + + enum State { + Uninitialized, + Locked, + Unlocked, + }; +}; + +Win11LimitedAccessFeatures::AtomicState + Win11LimitedAccessFeatures::mTaskbarState( + Win11LimitedAccessFeatures::Uninitialized); +Win11LimitedAccessFeatures::AtomicState + Win11LimitedAccessFeatures::mDefaultState( + Win11LimitedAccessFeatures::Uninitialized); + +RefPtr<Win11LimitedAccessFeaturesInterface> +CreateWin11LimitedAccessFeaturesInterface() { + RefPtr<Win11LimitedAccessFeaturesInterface> result( + new Win11LimitedAccessFeatures()); + return result; +} + +Result<bool, HRESULT> Win11LimitedAccessFeatures::Unlock( + Win11LimitedAccessFeatureType feature) { + AtomicState& atomicState = GetState(feature); + + const auto& lafInfo = limitedAccessFeatureInfo[static_cast<int>(feature)]; + + LAF_LOG( + LogLevel::Debug, "Limited Access Feature Info for %s. Feature %S, %S, %S", + lafInfo.debugName, lafInfo.feature, lafInfo.token, lafInfo.attestation); + + int state = atomicState; + if (state != Uninitialized) { + LAF_LOG(LogLevel::Debug, "%s already initialized! State = %s", + lafInfo.debugName, (state == Unlocked) ? "true" : "false"); + return (state == Unlocked); + } + + // If multiple threads read the state at the same time, and it's unitialized, + // both threads will unlock the feature. This situation is unlikely, but even + // if it happens, it's not a problem. + + auto result = UnlockImplementation(feature); + + int newState = Locked; + if (!result.isErr() && result.unwrap()) { + newState = Unlocked; + } + + atomicState = newState; + + return result; +} + +Win11LimitedAccessFeatures::AtomicState& Win11LimitedAccessFeatures::GetState( + Win11LimitedAccessFeatureType feature) { + switch (feature) { + case Win11LimitedAccessFeatureType::Taskbar: + return mTaskbarState; + + default: + LAF_LOG(LogLevel::Debug, "Missing feature type for %d", + static_cast<int>(feature)); + MOZ_ASSERT(false, + "Unhandled feature type! Add a new atomic state variable, add " + "that entry to the switch statement above, and add the proper " + "entries for the feature and the token."); + return mDefaultState; + } +} + +Result<bool, HRESULT> Win11LimitedAccessFeatures::UnlockImplementation( + Win11LimitedAccessFeatureType feature) { + ComPtr<ILimitedAccessFeaturesStatics> limitedAccessFeatures; + ComPtr<ILimitedAccessFeatureRequestResult> limitedAccessFeaturesResult; + + const auto& lafInfo = limitedAccessFeatureInfo[static_cast<int>(feature)]; + + HRESULT hr = RoGetActivationFactory( + HStringReference( + RuntimeClass_Windows_ApplicationModel_LimitedAccessFeatures) + .Get(), + IID_ILimitedAccessFeaturesStatics, &limitedAccessFeatures); + + if (!SUCCEEDED(hr)) { + LAF_LOG(LogLevel::Debug, "%s activation error. HRESULT = 0x%lx", + lafInfo.debugName, hr); + return Err(hr); + } + + hr = limitedAccessFeatures->TryUnlockFeature( + HStringReference(lafInfo.feature).Get(), + HStringReference(lafInfo.token).Get(), + HStringReference(lafInfo.attestation).Get(), + &limitedAccessFeaturesResult); + if (!SUCCEEDED(hr)) { + LAF_LOG(LogLevel::Debug, "%s unlock error. HRESULT = 0x%lx", + lafInfo.debugName, hr); + return Err(hr); + } + + LimitedAccessFeatureStatus status; + hr = limitedAccessFeaturesResult->get_Status(&status); + if (!SUCCEEDED(hr)) { + LAF_LOG(LogLevel::Debug, "%s get status error. HRESULT = 0x%lx", + lafInfo.debugName, hr); + return Err(hr); + } + + int state = Unlocked; + if ((status != LimitedAccessFeatureStatus_Available) && + (status != LimitedAccessFeatureStatus_AvailableWithoutToken)) { + LAF_LOG(LogLevel::Debug, "%s not available. HRESULT = 0x%lx", + lafInfo.debugName, hr); + state = Locked; + } + + return (state == Unlocked); +} + +#else // MINGW32 implementation + +RefPtr<Win11LimitedAccessFeaturesInterface> +CreateWin11LimitedAccessFeaturesInterface() { + RefPtr<Win11LimitedAccessFeaturesInterface> result; + return result; +} + +#endif diff --git a/browser/components/shell/Windows11LimitedAccessFeatures.h b/browser/components/shell/Windows11LimitedAccessFeatures.h new file mode 100644 index 0000000000..8e1ae5db7a --- /dev/null +++ b/browser/components/shell/Windows11LimitedAccessFeatures.h @@ -0,0 +1,53 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#ifndef SHELL_WINDOWS11LIMITEDACCESSFEATURES_H__ +#define SHELL_WINDOWS11LIMITEDACCESSFEATURES_H__ + +#include "nsISupportsImpl.h" +#include "mozilla/Result.h" +#include "mozilla/ResultVariant.h" +#include <winerror.h> +#include <windows.h> // for HRESULT +#include "mozilla/DefineEnum.h" +#include <winerror.h> + +MOZ_DEFINE_ENUM_CLASS(Win11LimitedAccessFeatureType, (Taskbar)); + +/** + * Class to manage unlocking limited access features on Windows 11. + * Unless stubbing for testing purposes, create objects of this + * class with CreateWin11LimitedAccessFeaturesInterface. + * + * Windows 11 requires certain features to be unlocked in order to work + * (for instance, the Win11 Taskbar pinning APIs). Call Unlock() + * to unlock them. Generally, results will be cached in atomic variables + * and future calls to Unlock will be as long as it takes + * to fetch an atomic variable. + */ +class Win11LimitedAccessFeaturesInterface { + public: + /** + * Unlocks the limited access features, if possible. + * + * Returns an error code on error, true on successful unlock, + * false on unlock failed (but with no error). + */ + virtual mozilla::Result<bool, HRESULT> Unlock( + Win11LimitedAccessFeatureType feature) = 0; + + /** + * Reference counting and cycle collection. + */ + NS_INLINE_DECL_REFCOUNTING(Win11LimitedAccessFeaturesInterface) + + protected: + virtual ~Win11LimitedAccessFeaturesInterface() {} +}; + +RefPtr<Win11LimitedAccessFeaturesInterface> +CreateWin11LimitedAccessFeaturesInterface(); + +#endif // SHELL_WINDOWS11LIMITEDACCESSFEATURES_H__ diff --git a/browser/components/shell/Windows11TaskbarPinning.cpp b/browser/components/shell/Windows11TaskbarPinning.cpp new file mode 100644 index 0000000000..350a58d59a --- /dev/null +++ b/browser/components/shell/Windows11TaskbarPinning.cpp @@ -0,0 +1,344 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "Windows11TaskbarPinning.h" +#include "Windows11LimitedAccessFeatures.h" + +#include "nsWindowsHelpers.h" +#include "MainThreadUtils.h" +#include "nsThreadUtils.h" +#include <strsafe.h> + +#include "mozilla/Result.h" +#include "mozilla/ResultVariant.h" + +#include "mozilla/Logging.h" + +static mozilla::LazyLogModule sLog("Windows11TaskbarPinning"); + +#define TASKBAR_PINNING_LOG(level, msg, ...) \ + MOZ_LOG(sLog, level, (msg, ##__VA_ARGS__)) + +#ifndef __MINGW32__ // WinRT headers not yet supported by MinGW + +# include <wrl.h> + +# include <inspectable.h> +# include <roapi.h> +# include <windows.services.store.h> +# include <windows.foundation.h> +# include <windows.ui.shell.h> + +using namespace mozilla; + +/** + * The Win32 SetEvent and WaitForSingleObject functions take HANDLE parameters + * which are typedefs of void*. When using nsAutoHandle, that means if you + * forget to call .get() first, everything still compiles and then doesn't work + * at runtime. For instance, calling SetEvent(mEvent) below would compile but + * not work at runtime and the waits would block forever. + * To ensure this isn't an issue, we wrap the event in a custom class here + * with the simple methods that we want on an event. + */ +class EventWrapper { + public: + EventWrapper() : mEvent(CreateEventW(nullptr, true, false, nullptr)) {} + + void Set() { SetEvent(mEvent.get()); } + + void Reset() { ResetEvent(mEvent.get()); } + + void Wait() { WaitForSingleObject(mEvent.get(), INFINITE); } + + private: + nsAutoHandle mEvent; +}; + +using namespace Microsoft::WRL; +using namespace Microsoft::WRL::Wrappers; +using namespace ABI::Windows; +using namespace ABI::Windows::UI::Shell; +using namespace ABI::Windows::Foundation; +using namespace ABI::Windows::ApplicationModel; + +static Result<ComPtr<ITaskbarManager>, HRESULT> InitializeTaskbar() { + ComPtr<IInspectable> taskbarStaticsInspectable; + + TASKBAR_PINNING_LOG(LogLevel::Debug, "Initializing taskbar"); + + HRESULT hr = RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_UI_Shell_TaskbarManager).Get(), + IID_ITaskbarManagerStatics, &taskbarStaticsInspectable); + if (FAILED(hr)) { + TASKBAR_PINNING_LOG(LogLevel::Debug, + "Taskbar: Failed to activate. HRESULT = 0x%lx", hr); + return Err(hr); + } + + ComPtr<ITaskbarManagerStatics> taskbarStatics; + + hr = taskbarStaticsInspectable.As(&taskbarStatics); + if (FAILED(hr)) { + TASKBAR_PINNING_LOG(LogLevel::Debug, "Failed statistics. HRESULT = 0x%lx", + hr); + return Err(hr); + } + + ComPtr<ITaskbarManager> taskbarManager; + + hr = taskbarStatics->GetDefault(&taskbarManager); + if (FAILED(hr)) { + TASKBAR_PINNING_LOG(LogLevel::Debug, + "Error getting TaskbarManager. HRESULT = 0x%lx", hr); + return Err(hr); + } + + TASKBAR_PINNING_LOG(LogLevel::Debug, + "TaskbarManager retrieved successfully!"); + return taskbarManager; +} + +Win11PinToTaskBarResult PinCurrentAppToTaskbarWin11( + bool aCheckOnly, const nsAString& aAppUserModelId, + nsAutoString aShortcutPath) { + MOZ_DIAGNOSTIC_ASSERT(!NS_IsMainThread(), + "PinCurrentAppToTaskbarWin11 should be called off main " + "thread only. It blocks, waiting on things to execute " + "asynchronously on the main thread."); + + { + RefPtr<Win11LimitedAccessFeaturesInterface> limitedAccessFeatures = + CreateWin11LimitedAccessFeaturesInterface(); + auto result = + limitedAccessFeatures->Unlock(Win11LimitedAccessFeatureType::Taskbar); + if (result.isErr()) { + auto hr = result.unwrapErr(); + TASKBAR_PINNING_LOG(LogLevel::Debug, + "Taskbar unlock: Error. HRESULT = 0x%lx", hr); + return {hr, Win11PinToTaskBarResultStatus::NotSupported}; + } + + if (result.unwrap() == false) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar unlock: failed. Not supported on this version of Windows."); + return {S_OK, Win11PinToTaskBarResultStatus::NotSupported}; + } + } + + HRESULT hr; + Win11PinToTaskBarResultStatus resultStatus = + Win11PinToTaskBarResultStatus::NotSupported; + + EventWrapper event; + + // Everything related to the taskbar and pinning must be done on the main / + // user interface thread or Windows will cause them to fail. + NS_DispatchToMainThread(NS_NewRunnableFunction( + "PinCurrentAppToTaskbarWin11", [&event, &hr, &resultStatus, aCheckOnly] { + auto CompletedOperations = + [&event, &resultStatus](Win11PinToTaskBarResultStatus status) { + resultStatus = status; + event.Set(); + }; + + auto result = InitializeTaskbar(); + if (result.isErr()) { + hr = result.unwrapErr(); + return CompletedOperations( + Win11PinToTaskBarResultStatus::NotSupported); + } + + ComPtr<ITaskbarManager> taskbar = result.unwrap(); + boolean supported; + hr = taskbar->get_IsSupported(&supported); + if (FAILED(hr) || !supported) { + if (FAILED(hr)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: error checking if supported. HRESULT = 0x%lx", hr); + } else { + TASKBAR_PINNING_LOG(LogLevel::Debug, "Taskbar: not supported."); + } + return CompletedOperations( + Win11PinToTaskBarResultStatus::NotSupported); + } + + if (aCheckOnly) { + TASKBAR_PINNING_LOG(LogLevel::Debug, "Taskbar: check succeeded."); + return CompletedOperations(Win11PinToTaskBarResultStatus::Success); + } + + boolean isAllowed = false; + hr = taskbar->get_IsPinningAllowed(&isAllowed); + if (FAILED(hr) || !isAllowed) { + if (FAILED(hr)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: error checking if pinning is allowed. HRESULT = " + "0x%lx", + hr); + } else { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: is pinning allowed error or isn't allowed right now. " + "It's not clear when it will be allowed. Possibly after a " + "reboot."); + } + return CompletedOperations( + Win11PinToTaskBarResultStatus::NotCurrentlyAllowed); + } + + ComPtr<IAsyncOperation<bool>> isPinnedOperation = nullptr; + hr = taskbar->IsCurrentAppPinnedAsync(&isPinnedOperation); + if (FAILED(hr)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: is current app pinned operation failed. HRESULT = " + "0x%lx", + hr); + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + // Copy the taskbar; don't use it as a reference. + // With the async calls, it's not guaranteed to still be valid + // if sent as a reference. + // resultStatus and event are not defined on the main thread and will + // be alive until the async functions complete, so they can be used as + // references. + auto isPinnedCallback = Callback<IAsyncOperationCompletedHandler< + bool>>([taskbar, &event, &resultStatus, &hr]( + IAsyncOperation<bool>* asyncInfo, + AsyncStatus status) mutable -> HRESULT { + auto CompletedOperations = + [&event, + &resultStatus](Win11PinToTaskBarResultStatus status) -> HRESULT { + resultStatus = status; + event.Set(); + return S_OK; + }; + + bool asyncOpSucceeded = status == AsyncStatus::Completed; + if (!asyncOpSucceeded) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: is pinned operation failed to complete."); + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + unsigned char isCurrentAppPinned = false; + hr = asyncInfo->GetResults(&isCurrentAppPinned); + if (FAILED(hr)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: is current app pinned check failed. HRESULT = 0x%lx", + hr); + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + if (isCurrentAppPinned) { + TASKBAR_PINNING_LOG(LogLevel::Debug, + "Taskbar: current app is already pinned."); + return CompletedOperations( + Win11PinToTaskBarResultStatus::AlreadyPinned); + } + + ComPtr<IAsyncOperation<bool>> requestPinOperation = nullptr; + hr = taskbar->RequestPinCurrentAppAsync(&requestPinOperation); + if (FAILED(hr)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: request pin current app operation creation failed. " + "HRESULT = 0x%lx", + hr); + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + auto pinAppCallback = Callback<IAsyncOperationCompletedHandler< + bool>>([CompletedOperations, &hr]( + IAsyncOperation<bool>* asyncInfo, + AsyncStatus status) -> HRESULT { + bool asyncOpSucceeded = status == AsyncStatus::Completed; + if (!asyncOpSucceeded) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: request pin current app operation did not " + "complete."); + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + unsigned char successfullyPinned = 0; + hr = asyncInfo->GetResults(&successfullyPinned); + if (FAILED(hr) || !successfullyPinned) { + if (FAILED(hr)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: request pin current app operation failed to pin " + "due to error. HRESULT = 0x%lx", + hr); + } else { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: request pin current app operation failed to pin"); + } + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: request pin current app operation succeeded"); + return CompletedOperations(Win11PinToTaskBarResultStatus::Success); + }); + + HRESULT pinOperationHR = + requestPinOperation->put_Completed(pinAppCallback.Get()); + if (FAILED(pinOperationHR)) { + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: request pin operation failed when setting completion " + "callback. HRESULT = 0x%lx", + hr); + hr = pinOperationHR; + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + // DO NOT SET event HERE. It will be set in the pin operation + // callback As in, operations are not completed, so don't call + // CompletedOperations + return S_OK; + }); + + HRESULT isPinnedOperationHR = + isPinnedOperation->put_Completed(isPinnedCallback.Get()); + if (FAILED(isPinnedOperationHR)) { + hr = isPinnedOperationHR; + TASKBAR_PINNING_LOG( + LogLevel::Debug, + "Taskbar: is pinned operation failed when setting completion " + "callback. HRESULT = 0x%lx", + hr); + return CompletedOperations(Win11PinToTaskBarResultStatus::Failed); + } + + // DO NOT SET event HERE. It will be set in the is pin operation + // callback As in, operations are not completed, so don't call + // CompletedOperations + })); + + // block until the pinning is completed on the main thread + event.Wait(); + + return {hr, resultStatus}; +} + +#else // MINGW32 implementation below + +Win11PinToTaskBarResult PinCurrentAppToTaskbarWin11( + bool aCheckOnly, const nsAString& aAppUserModelId, + nsAutoString aShortcutPath) { + return {S_OK, Win11PinToTaskBarResultStatus::NotSupported}; +} + +#endif // #ifndef __MINGW32__ // WinRT headers not yet supported by MinGW diff --git a/browser/components/shell/Windows11TaskbarPinning.h b/browser/components/shell/Windows11TaskbarPinning.h new file mode 100644 index 0000000000..c45fd66a5b --- /dev/null +++ b/browser/components/shell/Windows11TaskbarPinning.h @@ -0,0 +1,35 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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 exists to keep the Windows 11 Taskbar Pinning API + * related code as self-contained as possible. + */ + +#ifndef SHELL_WINDOWS11TASKBARPINNING_H__ +#define SHELL_WINDOWS11TASKBARPINNING_H__ + +#include "nsString.h" +#include <wrl.h> +#include <windows.h> // for HRESULT + +enum class Win11PinToTaskBarResultStatus { + Failed, + NotCurrentlyAllowed, + AlreadyPinned, + Success, + NotSupported, +}; + +struct Win11PinToTaskBarResult { + HRESULT errorCode; + Win11PinToTaskBarResultStatus result; +}; + +Win11PinToTaskBarResult PinCurrentAppToTaskbarWin11( + bool aCheckOnly, const nsAString& aAppUserModelId, + nsAutoString aShortcutPath); + +#endif // SHELL_WINDOWS11TASKBARPINNING_H__ diff --git a/browser/components/shell/moz.build b/browser/components/shell/moz.build index fe70623907..82e5afade7 100644 --- a/browser/components/shell/moz.build +++ b/browser/components/shell/moz.build @@ -50,6 +50,8 @@ elif CONFIG["OS_ARCH"] == "WINNT": ] SOURCES += [ "nsWindowsShellService.cpp", + "Windows11LimitedAccessFeatures.cpp", + "Windows11TaskbarPinning.cpp", "WindowsDefaultBrowser.cpp", "WindowsUserChoice.cpp", ] @@ -82,6 +84,7 @@ for var in ( ): DEFINES[var] = '"%s"' % CONFIG[var] + if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"] diff --git a/browser/components/shell/nsWindowsShellService.cpp b/browser/components/shell/nsWindowsShellService.cpp index 86c7694bf8..f7bca95b09 100644 --- a/browser/components/shell/nsWindowsShellService.cpp +++ b/browser/components/shell/nsWindowsShellService.cpp @@ -39,6 +39,7 @@ #include "nsIXULAppInfo.h" #include "nsINIParser.h" #include "nsNativeAppSupportWin.h" +#include "Windows11TaskbarPinning.h" #include <windows.h> #include <shellapi.h> @@ -1626,7 +1627,7 @@ nsWindowsShellService::GetTaskbarTabPins(nsTArray<nsString>& aShortcutPaths) { static nsresult PinCurrentAppToTaskbarWin10(bool aCheckOnly, const nsAString& aAppUserModelId, - nsAutoString aShortcutPath) { + const nsAString& aShortcutPath) { // The behavior here is identical if we're only checking or if we try to pin // but the app is already pinned so we update the variable accordingly. if (!aCheckOnly) { @@ -1695,6 +1696,28 @@ static nsresult PinCurrentAppToTaskbarImpl( } } + auto pinWithWin11TaskbarAPIResults = + PinCurrentAppToTaskbarWin11(aCheckOnly, aAppUserModelId, shortcutPath); + switch (pinWithWin11TaskbarAPIResults.result) { + case Win11PinToTaskBarResultStatus::NotSupported: + // Fall through to the win 10 mechanism + break; + + case Win11PinToTaskBarResultStatus::Success: + case Win11PinToTaskBarResultStatus::AlreadyPinned: + return NS_OK; + + case Win11PinToTaskBarResultStatus::NotCurrentlyAllowed: + case Win11PinToTaskBarResultStatus::Failed: + // return NS_ERROR_FAILURE; + + // Fall through to the old mechanism for now + // In future, we should be sending telemetry for when + // an error occurs or for when pinning is not allowed + // with the Win 11 APIs. + break; + } + return PinCurrentAppToTaskbarWin10(aCheckOnly, aAppUserModelId, shortcutPath); } @@ -1720,7 +1743,7 @@ static nsresult PinCurrentAppToTaskbarAsyncImpl(bool aCheckOnly, } nsAutoString aumid; - if (NS_WARN_IF(!mozilla::widget::WinTaskbar::GenerateAppUserModelID( + if (NS_WARN_IF(!mozilla::widget::WinTaskbar::GetAppUserModelID( aumid, aPrivateBrowsing))) { return NS_ERROR_FAILURE; } diff --git a/browser/components/shopping/content/shopping-message-bar.css b/browser/components/shopping/content/shopping-message-bar.css index f88b18be45..fe3320310f 100644 --- a/browser/components/shopping/content/shopping-message-bar.css +++ b/browser/components/shopping/content/shopping-message-bar.css @@ -34,45 +34,37 @@ button { margin-inline-start: 0; } -message-bar::part(container) { - align-items: start; - padding: 0.5rem 0.75rem; +.shopping-message-bar { + display: flex; + align-items: center; + padding-block: 0.5rem; gap: 0.75rem; -} -message-bar::part(icon) { - padding: 0; -} + &.analysis-in-progress { + align-items: start; + } -:host([type=analysis-in-progress]) message-bar::part(icon), -:host([type=reanalysis-in-progress]) message-bar::part(icon) { - border: 1px solid var(--icon-color); - border-radius: 50%; + .icon { + --message-bar-icon-url: url("chrome://global/skin/icons/info-filled.svg"); + width: var(--icon-size-default); + height: var(--icon-size-default); + flex-shrink: 0; + appearance: none; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + color: var(--icon-color); + background-image: var(--message-bar-icon-url); + } } -:host([type=analysis-in-progress]) message-bar::part(icon)::after, -:host([type=reanalysis-in-progress]) message-bar::part(icon)::after { +:host([type=analysis-in-progress]) .icon, +:host([type=reanalysis-in-progress]) .icon { --message-bar-icon-url: conic-gradient(var(--icon-color-information) var(--analysis-progress-pcent, 0%), transparent var(--analysis-progress-pcent, 0%)); + border: 1px solid var(--icon-color); border-radius: 50%; margin-block: 1px 0; margin-inline: 1px 0; - width: calc(var(--icon-size) - 2px); - height: calc(var(--icon-size) - 2px); -} - -:host([type=reanalysis-in-progress]) message-bar::part(container), -:host([type=stale]) message-bar::part(container) { - align-items: center; - background-color: transparent; - padding: 0; -} - -:host([type=thank-you-for-feedback]) message-bar::part(icon) { - --message-bar-icon-url: url("chrome://global/skin/icons/check-filled.svg"); -} - -:host([type=thank-you-for-feedback]) message-bar::part(container) { - text-align: start; - align-items: center; - gap: 12px; + width: calc(var(--icon-size-default) - 2px); + height: calc(var(--icon-size-default) - 2px); } diff --git a/browser/components/shopping/content/shopping-message-bar.mjs b/browser/components/shopping/content/shopping-message-bar.mjs index d6ec9c0888..c8d6510d5e 100644 --- a/browser/components/shopping/content/shopping-message-bar.mjs +++ b/browser/components/shopping/content/shopping-message-bar.mjs @@ -95,7 +95,8 @@ class ShoppingMessageBar extends MozLitElement { } staleWarningTemplate() { - return html`<message-bar> + return html`<div class="shopping-message-bar"> + <span class="icon"></span> <article id="message-bar-container" aria-labelledby="header"> <span data-l10n-id="shopping-message-bar-warning-stale-analysis-message-2" @@ -107,7 +108,7 @@ class ShoppingMessageBar extends MozLitElement { @click=${this.onClickAnalysisButton} ></button> </article> - </message-bar>`; + </div>`; } genericErrorTemplate() { @@ -163,11 +164,13 @@ class ShoppingMessageBar extends MozLitElement { } analysisInProgressTemplate() { - return html`<message-bar + return html`<div + class="shopping-message-bar analysis-in-progress" style=${styleMap({ "--analysis-progress-pcent": `${this.progress}%`, })} > + <span class="icon"></span> <article id="message-bar-container" aria-labelledby="header" @@ -184,15 +187,18 @@ class ShoppingMessageBar extends MozLitElement { data-l10n-id="shopping-message-bar-analysis-in-progress-message2" ></span> </article> - </message-bar>`; + </div>`; } reanalysisInProgressTemplate() { - return html`<message-bar + return html`<div + class="shopping-message-bar" + id="reanalysis-in-progress-message" style=${styleMap({ "--analysis-progress-pcent": `${this.progress}%`, })} > + <span class="icon"></span> <article id="message-bar-container" aria-labelledby="header" @@ -206,7 +212,7 @@ class ShoppingMessageBar extends MozLitElement { })}" ></span> </article> - </message-bar>`; + </div>`; } pageNotSupportedTemplate() { diff --git a/browser/components/shopping/content/shopping.html b/browser/components/shopping/content/shopping.html index 1c5d627869..8c5da71a96 100644 --- a/browser/components/shopping/content/shopping.html +++ b/browser/components/shopping/content/shopping.html @@ -30,7 +30,6 @@ href="chrome://browser/content/shopping/shopping-page.css" /> - <script src="chrome://global/content/elements/message-bar.js"></script> <script type="module" src="chrome://browser/content/shopping/onboarding.mjs" diff --git a/browser/components/shopping/metrics.yaml b/browser/components/shopping/metrics.yaml index b1869e859a..61be0c2985 100644 --- a/browser/components/shopping/metrics.yaml +++ b/browser/components/shopping/metrics.yaml @@ -29,6 +29,8 @@ shopping.settings: send_in_pings: - metrics telemetry_mirror: SHOPPING_NIMBUS_DISABLED + no_lint: + - GIFFT_NON_PING_LIFETIME component_opted_out: type: boolean @@ -49,6 +51,8 @@ shopping.settings: send_in_pings: - metrics telemetry_mirror: SHOPPING_COMPONENT_OPTED_OUT + no_lint: + - GIFFT_NON_PING_LIFETIME has_onboarded: type: boolean @@ -70,6 +74,8 @@ shopping.settings: send_in_pings: - metrics telemetry_mirror: SHOPPING_HAS_ONBOARDED + no_lint: + - GIFFT_NON_PING_LIFETIME disabled_ads: type: boolean @@ -90,6 +96,8 @@ shopping.settings: send_in_pings: - metrics telemetry_mirror: SHOPPING_DISABLED_ADS + no_lint: + - GIFFT_NON_PING_LIFETIME auto_open_user_disabled: type: boolean @@ -110,6 +118,8 @@ shopping.settings: send_in_pings: - metrics telemetry_mirror: SHOPPING_AUTO_OPEN_USER_DISABLED + no_lint: + - GIFFT_NON_PING_LIFETIME shopping: surface_displayed: diff --git a/browser/components/shopping/tests/browser/browser_inprogress_analysis.js b/browser/components/shopping/tests/browser/browser_inprogress_analysis.js index d2d1ddeb8c..67d0b54be2 100644 --- a/browser/components/shopping/tests/browser/browser_inprogress_analysis.js +++ b/browser/components/shopping/tests/browser/browser_inprogress_analysis.js @@ -127,8 +127,9 @@ add_task(async function test_in_progress_analysis_stale() { "shopping-message-bar should have progress" ); - let messageBarEl = - shoppingMessageBarEl?.shadowRoot.querySelector("message-bar"); + let messageBarEl = shoppingMessageBarEl?.shadowRoot.getElementById( + "reanalysis-in-progress-message" + ); is( messageBarEl?.getAttribute("style"), "--analysis-progress-pcent: 50%;", diff --git a/browser/components/sidebar/browser-sidebar.js b/browser/components/sidebar/browser-sidebar.js index 55664f8cfc..6cbac7c082 100644 --- a/browser/components/sidebar/browser-sidebar.js +++ b/browser/components/sidebar/browser-sidebar.js @@ -3,65 +3,127 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * SidebarUI controls showing and hiding the browser sidebar. + * SidebarController handles logic such as toggling sidebar panels, + * dynamically adding menubar menu items for the View -> Sidebar menu, + * and provides APIs for sidebar extensions, etc. */ -var SidebarUI = { +var SidebarController = { + makeSidebar({ elementId, ...rest }) { + return { + get sourceL10nEl() { + return document.getElementById(elementId); + }, + get title() { + return document.getElementById(elementId).getAttribute("label"); + }, + ...rest, + }; + }, + 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", - }), - ], + this._sidebars = new Map([ [ "viewHistorySidebar", - makeSidebar({ + this.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", + keyId: "key_gotoHistory", + menuL10nId: "menu-view-history-button", + revampL10nId: "sidebar-menu-history", + icon: `url("chrome://browser/content/firefoxview/view-history.svg")`, }), ], [ "viewTabsSidebar", - makeSidebar({ + this.makeSidebar({ elementId: "sidebar-switcher-tabs", url: this.sidebarRevampEnabled ? "chrome://browser/content/sidebar/sidebar-syncedtabs.html" : "chrome://browser/content/syncedtabs/sidebar.xhtml", menuId: "menu_tabsSidebar", + classAttribute: "sync-ui-item", + menuL10nId: "menu-view-synced-tabs-sidebar", + revampL10nId: "sidebar-menu-synced-tabs", + icon: `url("chrome://browser/content/firefoxview/view-syncedtabs.svg")`, }), ], - [ - "viewMegalistSidebar", - makeSidebar({ - elementId: "sidebar-switcher-megalist", - url: "chrome://global/content/megalist/megalist.html", - menuId: "menu_megalistSidebar", - }), - ], - ])); + ]); + + if (!this.sidebarRevampEnabled) { + this._sidebars.set( + "viewBookmarksSidebar", + this.makeSidebar({ + elementId: "sidebar-switcher-bookmarks", + url: "chrome://browser/content/places/bookmarksSidebar.xhtml", + menuId: "menu_bookmarksSidebar", + keyId: "viewBookmarksSidebarKb", + menuL10nId: "menu-view-bookmarks", + revampL10nId: "sidebar-menu-bookmarks", + }) + ); + if (this.megalistEnabled) { + this._sidebars.set( + "viewMegalistSidebar", + this.makeSidebar({ + elementId: "sidebar-switcher-megalist", + url: "chrome://global/content/megalist/megalist.html", + menuId: "menu_megalistSidebar", + menuL10nId: "menu-view-megalist-sidebar", + revampL10nId: "sidebar-menu-megalist", + }) + ); + } + } else { + this._sidebars.set( + "viewCustomizeSidebar", + this.makeSidebar({ + url: "chrome://browser/content/sidebar/sidebar-customize.html", + revampL10nId: "sidebar-menu-customize", + icon: `url("chrome://browser/skin/preferences/category-general.svg")`, + }) + ); + } + + return this._sidebars; + }, + + /** + * Returns a map of tools and extensions for use in the sidebar + */ + get toolsAndExtensions() { + if (this._toolsAndExtensions) { + return this._toolsAndExtensions; + } + + this._toolsAndExtensions = new Map(); + this.getSidebarPanels(["viewHistorySidebar", "viewTabsSidebar"]).forEach( + tool => { + this._toolsAndExtensions.set(tool.commandID, { + view: tool.commandID, + icon: tool.icon, + l10nId: tool.revampL10nId, + disabled: false, + }); + } + ); + this.getExtensions().forEach(extension => { + this._toolsAndExtensions.set(extension.commandID, { + view: extension.commandID, + extensionId: extension.extensionId, + icon: extension.icon, + tooltiptext: extension.label, + disabled: false, + }); + }); + return this._toolsAndExtensions; }, // Avoid getting the browser element from init() to avoid triggering the @@ -120,9 +182,18 @@ var SidebarUI = { this._switcherTarget = document.getElementById("sidebar-switcher-target"); this._switcherArrow = document.getElementById("sidebar-switcher-arrow"); + const menubar = document.getElementById("viewSidebarMenu"); + for (const [commandID, sidebar] of this.sidebars.entries()) { + if (!Object.hasOwn(sidebar, "extensionId")) { + // registerExtension() already creates menu items for extensions. + const menuitem = this.createMenuItem(commandID, sidebar); + menubar.appendChild(menuitem); + } + } + if (this.sidebarRevampEnabled) { - await import("chrome://browser/content/sidebar/sidebar-launcher.mjs"); - document.getElementById("sidebar-launcher").hidden = false; + await import("chrome://browser/content/sidebar/sidebar-main.mjs"); + document.getElementById("sidebar-main").hidden = false; document.getElementById("sidebar-header").hidden = true; } else { this._switcherTarget.addEventListener("command", () => { @@ -144,12 +215,7 @@ var SidebarUI = { const sideMenuPopupItem = document.getElementById( "sidebar-switcher-megalist" ); - sideMenuPopupItem.style.display = Services.prefs.getBoolPref( - "browser.megalist.enabled", - false - ) - ? "" - : "none"; + sideMenuPopupItem.style.display = this.megalistEnabled ? "" : "none"; }, setMegalistMenubarVisibility(aEvent) { @@ -160,10 +226,7 @@ var SidebarUI = { // Show the megalist item if enabled const megalistItem = popup.querySelector("#menu_megalistSidebar"); - megalistItem.hidden = !Services.prefs.getBoolPref( - "browser.megalist.enabled", - false - ); + megalistItem.hidden = !this.megalistEnabled; }, uninit() { @@ -172,22 +235,6 @@ var SidebarUI = { 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"); @@ -338,30 +385,30 @@ var SidebarUI = { [...browser.children].forEach((node, i) => { node.style.order = i + 1; }); - let sidebarLauncher = document.querySelector("sidebar-launcher"); + let sidebarMain = document.querySelector("sidebar-main"); 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 + // DOM ordering is: sidebar-main | sidebar-box | splitter | appcontent | + // Want to display as: | appcontent | splitter | sidebar-box | sidebar-main + // So we just swap box and appcontent ordering and move sidebar-main 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; + sidebarMain.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); + sidebarMain.setAttribute("positionend", true); } else { this._box.removeAttribute("positionend"); - sidebarLauncher.removeAttribute("positionend"); + sidebarMain.removeAttribute("positionend"); } this.hideSwitcherPanel(); - let content = SidebarUI.browser.contentWindow; + let content = SidebarController.browser.contentWindow; if (content && content.updatePosition) { content.updatePosition(); } @@ -378,7 +425,7 @@ var SidebarUI = { // 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; + let sourceUI = sourceWindow.SidebarController; if (!sourceUI || !sourceUI._box) { // no source UI or no _box means we also can't adopt the state. return false; @@ -543,17 +590,212 @@ var SidebarUI = { _loadSidebarExtension(commandID) { let sidebar = this.sidebars.get(commandID); - let { extensionId } = sidebar; - if (extensionId) { - SidebarUI.browser.contentWindow.loadPanel( - extensionId, - sidebar.panel, - sidebar.browserStyle - ); + if (typeof sidebar.onload === "function") { + sidebar.onload(); } }, /** + * Sets the disabled property for a tool when customizing sidebar options + * + * @param {string} commandID + */ + toggleTool(commandID) { + let toggledTool = this.toolsAndExtensions.get(commandID); + toggledTool.disabled = !toggledTool.disabled; + if (!toggledTool.disabled) { + // If re-enabling tool, remove from the map and add it to the end + this.toolsAndExtensions.delete(commandID); + this.toolsAndExtensions.set(commandID, toggledTool); + } + window.dispatchEvent(new CustomEvent("SidebarItemChanged")); + }, + + addOrUpdateExtension(commandID, extension) { + if (this.toolsAndExtensions.has(commandID)) { + // Update existing extension + let extensionToUpdate = this.toolsAndExtensions.get(commandID); + extensionToUpdate.icon = extension.icon; + extensionToUpdate.tooltiptext = extension.label; + window.dispatchEvent(new CustomEvent("SidebarItemChanged")); + } else { + // Add new extension + this.toolsAndExtensions.set(commandID, { + view: commandID, + extensionId: extension.extensionId, + icon: extension.icon, + tooltiptext: extension.label, + disabled: false, + }); + window.dispatchEvent(new CustomEvent("SidebarItemAdded")); + } + }, + + /** + * Add menu items for a browser extension. Add the extension to the + * `sidebars` map. + * + * @param {string} commandID + * @param {object} props + */ + registerExtension(commandID, props) { + const sidebar = { + title: props.title, + url: "chrome://browser/content/webext-panels.xhtml", + menuId: props.menuId, + switcherMenuId: `sidebarswitcher_menu_${commandID}`, + keyId: `ext-key-id-${commandID}`, + label: props.title, + icon: props.icon, + classAttribute: "menuitem-iconic webextension-menuitem", + // The following properties are specific to extensions + extensionId: props.extensionId, + onload: props.onload, + }; + this.sidebars.set(commandID, sidebar); + + // Insert a menuitem for View->Show Sidebars. + const menuitem = this.createMenuItem(commandID, sidebar); + document.getElementById("viewSidebarMenu").appendChild(menuitem); + this.addOrUpdateExtension(commandID, sidebar); + + if (!this.sidebarRevampEnabled) { + // Insert a toolbarbutton for the sidebar dropdown selector. + let switcherMenuitem = this.createMenuItem(commandID, sidebar); + switcherMenuitem.setAttribute("id", sidebar.switcherMenuId); + switcherMenuitem.removeAttribute("type"); + + let separator = document.getElementById("sidebar-extensions-separator"); + separator.parentNode.insertBefore(switcherMenuitem, separator); + } + this._setExtensionAttributes( + commandID, + { icon: props.icon, label: props.title }, + sidebar + ); + }, + + /** + * Create a menu item for the View>Sidebars submenu in the menubar. + * + * @param {string} commandID + * @param {object} sidebar + * @returns {Element} + */ + createMenuItem(commandID, sidebar) { + const menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("id", sidebar.menuId); + menuitem.setAttribute("type", "checkbox"); + menuitem.addEventListener("command", () => this.toggle(commandID)); + if (sidebar.classAttribute) { + menuitem.setAttribute("class", sidebar.classAttribute); + } + if (sidebar.keyId) { + menuitem.setAttribute("key", sidebar.keyId); + } + if (sidebar.menuL10nId) { + menuitem.dataset.l10nId = sidebar.menuL10nId; + } + return menuitem; + }, + + /** + * Update attributes on all existing menu items for a browser extension. + * + * @param {string} commandID + * @param {object} attributes + * @param {string} attributes.icon + * @param {string} attributes.label + * @param {boolean} needsRefresh + */ + setExtensionAttributes(commandID, attributes, needsRefresh) { + const sidebar = this.sidebars.get(commandID); + this._setExtensionAttributes(commandID, attributes, sidebar, needsRefresh); + this.addOrUpdateExtension(commandID, sidebar); + }, + + _setExtensionAttributes( + commandID, + { icon, label }, + sidebar, + needsRefresh = false + ) { + sidebar.icon = icon; + sidebar.label = label; + + const updateAttributes = el => { + el.style.setProperty("--webextension-menuitem-image", sidebar.icon); + el.setAttribute("label", sidebar.label); + }; + + updateAttributes(document.getElementById(sidebar.menuId), sidebar); + const switcherMenu = document.getElementById(sidebar.switcherMenuId); + if (switcherMenu) { + updateAttributes(switcherMenu, sidebar); + } + if (this.initialized && this.currentID === commandID) { + // Update the sidebar if this extension is the current sidebar. + updateAttributes(this._switcherTarget, sidebar); + this.title = label; + if (this.isOpen && needsRefresh) { + this.show(commandID); + } + } + }, + + /** + * Retrieve the list of registered browser extensions. + * + * @returns {Array} + */ + getExtensions() { + const extensions = []; + for (const [commandID, sidebar] of this.sidebars.entries()) { + if (Object.hasOwn(sidebar, "extensionId")) { + extensions.push({ commandID, ...sidebar }); + } + } + return extensions; + }, + + /** + * Retrieve the list of sidebar panels + * + * @param {Array} commandIds + * @returns {Array} + */ + getSidebarPanels(commandIds) { + const tools = []; + for (const commandID of commandIds) { + const sidebar = this.sidebars.get(commandID); + if (sidebar) { + tools.push({ commandID, ...sidebar }); + } + } + return tools; + }, + + /** + * Remove a browser extension. + * + * @param {string} commandID + */ + removeExtension(commandID) { + const sidebar = this.sidebars.get(commandID); + if (!sidebar) { + return; + } + if (this.currentID === commandID) { + this.hide(); + } + document.getElementById(sidebar.menuId)?.remove(); + document.getElementById(sidebar.switcherMenuId)?.remove(); + this.sidebars.delete(commandID); + this.toolsAndExtensions.delete(commandID); + window.dispatchEvent(new CustomEvent("SidebarItemRemoved")); + }, + + /** * Show the sidebar. * * This wraps the internal method, including a ping to telemetry. @@ -632,7 +874,17 @@ var SidebarUI = { this._box.setAttribute("checked", "true"); this._box.setAttribute("sidebarcommand", commandID); - let { url, title, sourceL10nEl } = this.sidebars.get(commandID); + let { icon, url, title, sourceL10nEl } = this.sidebars.get(commandID); + if (icon) { + this._switcherTarget.style.setProperty( + "--webextension-menuitem-image", + icon + ); + } else { + this._switcherTarget.style.removeProperty( + "--webextension-menuitem-image" + ); + } // use to live update <tree> elements if the locale changes this.lastOpenedId = commandID; @@ -730,15 +982,21 @@ var SidebarUI = { // Add getters related to the position here, since we will want them // available for both startDelayedLoad and init. XPCOMUtils.defineLazyPreferenceGetter( - SidebarUI, + SidebarController, "_positionStart", - SidebarUI.POSITION_START_PREF, + SidebarController.POSITION_START_PREF, true, - SidebarUI.setPosition.bind(SidebarUI) + SidebarController.setPosition.bind(SidebarController) ); XPCOMUtils.defineLazyPreferenceGetter( - SidebarUI, + SidebarController, "sidebarRevampEnabled", "sidebar.revamp", false ); +XPCOMUtils.defineLazyPreferenceGetter( + SidebarController, + "megalistEnabled", + "browser.megalist.enabled", + false +); diff --git a/browser/components/sidebar/jar.mn b/browser/components/sidebar/jar.mn index 8a7071ca72..f9624b2a55 100644 --- a/browser/components/sidebar/jar.mn +++ b/browser/components/sidebar/jar.mn @@ -4,8 +4,11 @@ browser.jar: content/browser/sidebar/browser-sidebar.js - content/browser/sidebar/sidebar-launcher.css - content/browser/sidebar/sidebar-launcher.mjs + content/browser/sidebar/sidebar-customize.css + content/browser/sidebar/sidebar-customize.html + content/browser/sidebar/sidebar-customize.mjs + content/browser/sidebar/sidebar-main.css + content/browser/sidebar/sidebar-main.mjs content/browser/sidebar/sidebar-history.html content/browser/sidebar/sidebar-history.mjs content/browser/sidebar/sidebar-page.mjs diff --git a/browser/components/sidebar/moz.build b/browser/components/sidebar/moz.build index d988c0ff9b..6310d973e0 100644 --- a/browser/components/sidebar/moz.build +++ b/browser/components/sidebar/moz.build @@ -5,3 +5,5 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] diff --git a/browser/components/sidebar/sidebar-customize.css b/browser/components/sidebar/sidebar-customize.css new file mode 100644 index 0000000000..d78477e8ba --- /dev/null +++ b/browser/components/sidebar/sidebar-customize.css @@ -0,0 +1,57 @@ +/* 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/. */ + +.container { + padding-inline: var(--space-small); + font-size: 15px; +} + +.customize-header { + display: flex; + justify-content: space-between; + align-items: center; + -moz-context-properties: fill; + fill: currentColor; + color: currentColor; + + .customize-close-button::part(button) { + background-image: url("chrome://global/skin/icons/close-12.svg"); + } +} + +.customize-firefox-tools { + .inputs { + display: flex; + flex-direction: column; + gap: var(--space-medium); + } + + .input-wrapper { + display: flex; + align-items: center; + gap: var(--space-small); + + > input { + margin-inline-start: 0; + } + + > label { + display: inline-flex; + align-items: center; + gap: var(--space-small); + font-size: 0.9em; + } + + .icon { + -moz-context-properties: fill; + fill: currentColor; + background-image: var(--tool-icon); + background-size: var(--icon-size-default); + width: var(--icon-size-default); + height: var(--icon-size-default); + background-position: center; + background-repeat: no-repeat; + } + } +} diff --git a/browser/components/sidebar/sidebar-customize.html b/browser/components/sidebar/sidebar-customize.html new file mode 100644 index 0000000000..24ba42c210 --- /dev/null +++ b/browser/components/sidebar/sidebar-customize.html @@ -0,0 +1,31 @@ +<!-- 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="sidebar-customize-header"></title> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="preview/sidebar.ftl" /> + <link + rel="stylesheet" + href="chrome://browser/content/sidebar/sidebar.css" + /> + <script + type="module" + src="chrome://browser/content/sidebar/sidebar-customize.mjs" + ></script> + <script src="chrome://browser/content/contentTheme.js"></script> + </head> + + <body> + <sidebar-customize /> + </body> +</html> diff --git a/browser/components/sidebar/sidebar-customize.mjs b/browser/components/sidebar/sidebar-customize.mjs new file mode 100644 index 0000000000..e4ad5fe5dd --- /dev/null +++ b/browser/components/sidebar/sidebar-customize.mjs @@ -0,0 +1,116 @@ +/* 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, styleMap } from "chrome://global/content/vendor/lit.all.mjs"; + +import { SidebarPage } from "./sidebar-page.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; + +const l10nMap = new Map([ + ["viewHistorySidebar", "sidebar-customize-history"], + ["viewTabsSidebar", "sidebar-customize-synced-tabs"], +]); + +export class SidebarCustomize extends SidebarPage { + static queries = { + toolInputs: { all: ".customize-firefox-tools input" }, + }; + + connectedCallback() { + super.connectedCallback(); + window.addEventListener("SidebarItemChanged", this); + } + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("SidebarItemChanged", this); + } + + get sidebarLauncher() { + return this.getWindow().document.querySelector("sidebar-launcher"); + } + + getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; + } + + closeCustomizeView(e) { + e.preventDefault(); + let view = e.target.getAttribute("view"); + this.getWindow().SidebarController.toggle(view); + } + + getTools() { + const toolsMap = new Map( + [...this.getWindow().SidebarController.toolsAndExtensions] + // eslint-disable-next-line no-unused-vars + .filter(([key, val]) => !val.extensionId) + ); + return toolsMap; + } + + handleEvent(e) { + switch (e.type) { + case "SidebarItemChanged": + this.requestUpdate(); + break; + } + } + + async onToggleInput(e) { + e.preventDefault(); + this.getWindow().SidebarController.toggleTool(e.target.id); + } + + getInputL10nId(view) { + return l10nMap.get(view); + } + + inputTemplate(tool) { + return html`<div class="input-wrapper"> + <input + type="checkbox" + id=${tool.view} + name=${tool.view} + @change=${this.onToggleInput} + ?checked=${!tool.disabled} + /> + <label for=${tool.view} + ><span class="icon ghost-icon" style=${styleMap({ + "--tool-icon": tool.icon, + })} role="presentation"/></span><span + data-l10n-id=${this.getInputL10nId(tool.view)} + ></span + ></label> + </div>`; + } + + render() { + return html` + ${this.stylesheet()} + <link rel="stylesheet" href="chrome://browser/content/sidebar/sidebar-customize.css"></link> + <div class="container"> + <div class="customize-header"> + <h2 data-l10n-id="sidebar-customize-header"></h2> + <moz-button + class="customize-close-button" + @click=${this.closeCustomizeView} + view="viewCustomizeSidebar" + size="default" + type="icon ghost" + > + </moz-button> + </div> + <div class="customize-firefox-tools"> + <h5 data-l10n-id="sidebar-customize-firefox-tools"></h5> + <div class="inputs"> + ${[...this.getTools().values()].map(tool => this.inputTemplate(tool))} + </div> + </div> + </div> + `; + } +} + +customElements.define("sidebar-customize", SidebarCustomize); diff --git a/browser/components/sidebar/sidebar-history.html b/browser/components/sidebar/sidebar-history.html index f1df5c507a..9544aa58d6 100644 --- a/browser/components/sidebar/sidebar-history.html +++ b/browser/components/sidebar/sidebar-history.html @@ -13,7 +13,6 @@ <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" /> @@ -23,6 +22,18 @@ /> <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://global/content/elements/moz-card.mjs" + ></script> + <script + type="module" src="chrome://browser/content/sidebar/sidebar-history.mjs" ></script> <script src="chrome://browser/content/contentTheme.js"></script> diff --git a/browser/components/sidebar/sidebar-history.mjs b/browser/components/sidebar/sidebar-history.mjs index 6c662b2c6f..c381d48eed 100644 --- a/browser/components/sidebar/sidebar-history.mjs +++ b/browser/components/sidebar/sidebar-history.mjs @@ -2,22 +2,25 @@ * 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 = {}; + import { html, when } from "chrome://global/content/vendor/lit.all.mjs"; +import { navigateToLink } from "chrome://browser/content/firefoxview/helpers.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"; +ChromeUtils.defineESModuleGetters(lazy, { + HistoryController: "resource:///modules/HistoryController.sys.mjs", +}); const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; export class SidebarHistory extends SidebarPage { + static queries = { + lists: { all: "fxview-tab-list" }, + searchTextbox: "fxview-search-textbox", + }; + constructor() { super(); this._started = false; @@ -25,13 +28,13 @@ export class SidebarHistory extends SidebarPage { this.maxTabsLength = -1; } - controller = new HistoryController(this, { + controller = new lazy.HistoryController(this, { component: "sidebar", }); connectedCallback() { super.connectedCallback(); - this.controller.updateAllHistoryItems(); + this.controller.updateCache(); } onPrimaryAction(e) { @@ -48,38 +51,34 @@ export class SidebarHistory extends SidebarPage { get cardsTemplate() { if (this.controller.searchResults) { return this.#searchResultsTemplate(); - } else if (this.controller.allHistoryItems.size) { + } else if (!this.controller.isHistoryEmpty) { 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 this.controller.historyVisits.map(historyItem => { + let dateArg = JSON.stringify({ date: historyItem.items[0].time }); + return 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() { @@ -154,12 +153,8 @@ export class SidebarHistory extends SidebarPage { </moz-card>`; } - async onChangeSortOption(e) { - await this.controller.onChangeSortOption(e); - } - - async onSearchQuery(e) { - await this.controller.onSearchQuery(e); + onSearchQuery(e) { + this.controller.onSearchQuery(e); } getTabItems(items) { @@ -188,14 +183,6 @@ export class SidebarHistory extends SidebarPage { </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 deleted file mode 100644 index b033a650b3..0000000000 --- a/browser/components/sidebar/sidebar-launcher.css +++ /dev/null @@ -1,34 +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 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 deleted file mode 100644 index 85eb94b6ca..0000000000 --- a/browser/components/sidebar/sidebar-launcher.mjs +++ /dev/null @@ -1,169 +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, - 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-main.css b/browser/components/sidebar/sidebar-main.css new file mode 100644 index 0000000000..14fac4d773 --- /dev/null +++ b/browser/components/sidebar/sidebar-main.css @@ -0,0 +1,29 @@ +/* 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; + } +} + +.actions-list { + display: flex; + flex-direction: column; + justify-content: end; + gap: var(--space-xsmall); +} + +.icon-button::part(button) { + background-image: var(--action-icon); +} diff --git a/browser/components/sidebar/sidebar-main.mjs b/browser/components/sidebar/sidebar-main.mjs new file mode 100644 index 0000000000..c7c65d18e8 --- /dev/null +++ b/browser/components/sidebar/sidebar-main.mjs @@ -0,0 +1,163 @@ +/* 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"; + +/** + * Sidebar with expanded and collapsed states that provides entry points + * to various sidebar panels and sidebar extensions. + */ +export default class SidebarMain extends MozLitElement { + static properties = { + bottomActions: { type: Array }, + selectedView: { type: String }, + sidebarItems: { type: Array }, + open: { type: Boolean }, + }; + + static queries = { + extensionButtons: { all: ".tools-and-extensions > moz-button[extension]" }, + toolButtons: { all: ".tools-and-extensions > moz-button:not([extension])" }, + customizeButton: ".bottom-actions > moz-button[view=viewCustomizeSidebar]", + }; + + constructor() { + super(); + this.bottomActions = []; + this.selectedView = window.SidebarController.currentID; + this.open = window.SidebarController.isOpen; + } + + connectedCallback() { + super.connectedCallback(); + this._sidebarBox = document.getElementById("sidebar-box"); + this._sidebarBox.addEventListener("sidebar-show", this); + this._sidebarBox.addEventListener("sidebar-hide", this); + + window.addEventListener("SidebarItemAdded", this); + window.addEventListener("SidebarItemChanged", this); + window.addEventListener("SidebarItemRemoved", this); + + this.setCustomize(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._sidebarBox.removeEventListener("sidebar-show", this); + this._sidebarBox.removeEventListener("sidebar-hide", this); + + window.removeEventListener("SidebarItemAdded", this); + window.removeEventListener("SidebarItemChanged", this); + window.removeEventListener("SidebarItemRemoved", this); + } + + 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; + } + + getToolsAndExtensions() { + return window.SidebarController.toolsAndExtensions; + } + + setCustomize() { + this.bottomActions.push( + ...window.SidebarController.getSidebarPanels([ + "viewCustomizeSidebar", + ]).map(({ commandID, icon, revampL10nId }) => ({ + l10nId: revampL10nId, + icon, + view: commandID, + })) + ); + } + + handleEvent(e) { + switch (e.type) { + case "sidebar-show": + this.selectedView = e.detail.viewId; + this.open = true; + break; + case "sidebar-hide": + this.open = false; + break; + case "SidebarItemAdded": + case "SidebarItemChanged": + case "SidebarItemRemoved": + this.requestUpdate(); + break; + } + } + + showView(e) { + let view = e.target.getAttribute("view"); + window.SidebarController.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 })} + ?extension=${action.view?.includes("-sidebar-action")} + > + </moz-button>`; + } + + render() { + let toolsAndExtensions = this.getToolsAndExtensions() + ? this.getToolsAndExtensions() + : new Map(); + return html` + <link + rel="stylesheet" + href="chrome://browser/content/sidebar/sidebar-main.css" + /> + <div class="wrapper"> + <div class="tools-and-extensions actions-list"> + ${[...toolsAndExtensions.values()] + .filter(toolOrExtension => !toolOrExtension.disabled) + .map(action => this.entrypointTemplate(action))} + </div> + <div class="bottom-actions actions-list"> + ${this.bottomActions.map(action => this.entrypointTemplate(action))} + </div> + </div> + `; + } +} +customElements.define("sidebar-main", SidebarMain); diff --git a/browser/components/sidebar/sidebar-syncedtabs.html b/browser/components/sidebar/sidebar-syncedtabs.html index 6c4874b9ea..aa1ae7e2e2 100644 --- a/browser/components/sidebar/sidebar-syncedtabs.html +++ b/browser/components/sidebar/sidebar-syncedtabs.html @@ -13,7 +13,6 @@ <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 diff --git a/browser/components/sidebar/sidebar.ftl b/browser/components/sidebar/sidebar.ftl index 2a5ef75d83..e76fda863e 100644 --- a/browser/components/sidebar/sidebar.ftl +++ b/browser/components/sidebar/sidebar.ftl @@ -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/. -sidebar-launcher-insights = +sidebar-main-insights = .title = Insights ## Variables: @@ -24,3 +24,10 @@ sidebar-history-date-prev-month = # $query (String) - The search query used for searching through browser history. sidebar-search-results-header = .heading = Search results for “{ $query }” + +sidebar-menu-customize = + .title = Customize sidebar +sidebar-customize-header = Customize sidebar +sidebar-customize-firefox-tools = { -brand-product-name } tools +sidebar-customize-history = History +sidebar-customize-synced-tabs = Tabs from other devices diff --git a/browser/components/sidebar/tests/browser/browser.toml b/browser/components/sidebar/tests/browser/browser.toml new file mode 100644 index 0000000000..5df032495c --- /dev/null +++ b/browser/components/sidebar/tests/browser/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] + +["browser_customize_sidebar.js"] + +["browser_extensions_sidebar.js"] + +["browser_history_sidebar.js"] diff --git a/browser/components/sidebar/tests/browser/browser_customize_sidebar.js b/browser/components/sidebar/tests/browser/browser_customize_sidebar.js new file mode 100644 index 0000000000..ab26823d08 --- /dev/null +++ b/browser/components/sidebar/tests/browser/browser_customize_sidebar.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(() => SpecialPowers.pushPrefEnv({ set: [["sidebar.revamp", true]] })); +registerCleanupFunction(() => SpecialPowers.popPrefEnv()); + +add_task(async function test_customize_sidebar_actions() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const { document } = win; + const sidebar = document.getElementById("sidebar-main"); + ok(sidebar, "Sidebar is shown."); + + const button = sidebar.customizeButton; + const promiseFocused = BrowserTestUtils.waitForEvent(win, "SidebarFocused"); + button.click(); + await promiseFocused; + let customizeDocument = win.SidebarController.browser.contentDocument; + const customizeComponent = + customizeDocument.querySelector("sidebar-customize"); + let toolEntrypointsCount = sidebar.toolButtons.length; + is( + customizeComponent.toolInputs.length, + toolEntrypointsCount, + `${toolEntrypointsCount} inputs to toggle Firefox Tools are shown in the Customize Menu.` + ); + for (const toolInput of customizeComponent.toolInputs) { + toolInput.click(); + await BrowserTestUtils.waitForCondition(() => { + let toggledTool = win.SidebarController.toolsAndExtensions.get( + toolInput.name + ); + return toggledTool.disabled; + }, `The entrypoint for ${toolInput.name} has been disabled in the sidebar.`); + toolEntrypointsCount = sidebar.toolButtons.length; + is( + toolEntrypointsCount, + 1, + `The button for the ${toolInput.name} entrypoint has been removed.` + ); + toolInput.click(); + await BrowserTestUtils.waitForCondition(() => { + let toggledTool = win.SidebarController.toolsAndExtensions.get( + toolInput.name + ); + return !toggledTool.disabled; + }, `The entrypoint for ${toolInput.name} has been re-enabled in the sidebar.`); + toolEntrypointsCount = sidebar.toolButtons.length; + is( + toolEntrypointsCount, + 2, + `The button for the ${toolInput.name} entrypoint has been added back.` + ); + // Check ordering + is( + sidebar.toolButtons[1].getAttribute("view"), + toolInput.name, + `The button for the ${toolInput.name} entrypoint has been added back to the end of the list of tools/extensions entrypoints` + ); + } + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sidebar/tests/browser/browser_extensions_sidebar.js b/browser/components/sidebar/tests/browser/browser_extensions_sidebar.js new file mode 100644 index 0000000000..0413849fd2 --- /dev/null +++ b/browser/components/sidebar/tests/browser/browser_extensions_sidebar.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(() => SpecialPowers.pushPrefEnv({ set: [["sidebar.revamp", true]] })); +registerCleanupFunction(() => SpecialPowers.popPrefEnv()); + +const imageBuffer = imageBufferFromDataURI( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==" +); + +function imageBufferFromDataURI(encodedImageData) { + const decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} + +/* global browser */ +const extData = { + manifest: { + sidebar_action: { + default_icon: "default.png", + default_panel: "default.html", + default_title: "Default Title", + }, + }, + useAddonManager: "temporary", + + files: { + "default.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + "1.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/></head> + <body> + A Test Sidebar + </body></html> + `, + "default.png": imageBuffer, + "1.png": imageBuffer, + }, + + background() { + browser.test.onMessage.addListener(async ({ msg, data }) => { + switch (msg) { + case "set-icon": + await browser.sidebarAction.setIcon({ path: data }); + break; + case "set-panel": + await browser.sidebarAction.setPanel({ panel: data }); + break; + case "set-title": + await browser.sidebarAction.setTitle({ title: data }); + break; + } + browser.test.sendMessage("done"); + }); + }, +}; + +async function sendMessage(extension, msg, data) { + extension.sendMessage({ msg, data }); + await extension.awaitMessage("done"); +} + +add_task(async function test_extension_sidebar_actions() { + // TODO: Once `sidebar.revamp` is either enabled by default, or removed + // entirely, this test should run in the current window, and it should only + // await one "sidebar" message. + const win = await BrowserTestUtils.openNewBrowserWindow(); + const { document } = win; + const sidebar = document.getElementById("sidebar-main"); + ok(sidebar, "Sidebar is shown."); + + const extension = ExtensionTestUtils.loadExtension({ ...extData }); + await extension.startup(); + await extension.awaitMessage("sidebar"); + await extension.awaitMessage("sidebar"); + is(sidebar.extensionButtons.length, 1, "Extension is shown in the sidebar."); + + // Default icon and title matches. + const button = sidebar.extensionButtons[0]; + let iconUrl = `moz-extension://${extension.uuid}/default.png`; + is( + button.style.getPropertyValue("--action-icon"), + `image-set(url("${iconUrl}"), url("${iconUrl}") 2x)`, + "Extension has the correct icon." + ); + is(button.title, "Default Title", "Extension has the correct title."); + + // Icon can be updated. + await sendMessage(extension, "set-icon", "1.png"); + await sidebar.updateComplete; + iconUrl = `moz-extension://${extension.uuid}/1.png`; + is( + button.style.getPropertyValue("--action-icon"), + `image-set(url("${iconUrl}"), url("${iconUrl}") 2x)`, + "Extension has updated icon." + ); + + // Title can be updated. + await sendMessage(extension, "set-title", "Updated Title"); + await sidebar.updateComplete; + is(button.title, "Updated Title", "Extension has updated title."); + + // Panel can be updated. + await sendMessage(extension, "set-panel", "1.html"); + const panelUrl = `moz-extension://${extension.uuid}/1.html`; + await TestUtils.waitForCondition(() => { + const browser = SidebarController.browser.contentDocument.getElementById( + "webext-panels-browser" + ); + return browser.currentURI.spec === panelUrl; + }, "The new panel is visible."); + + await extension.unload(); + await sidebar.updateComplete; + is( + sidebar.extensionButtons.length, + 0, + "Extension is removed from the sidebar." + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_open_new_window_after_install() { + const extension = ExtensionTestUtils.loadExtension({ ...extData }); + await extension.startup(); + await extension.awaitMessage("sidebar"); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const { document } = win; + const sidebar = document.getElementById("sidebar-main"); + ok(sidebar, "Sidebar is shown."); + await extension.awaitMessage("sidebar"); + is( + sidebar.extensionButtons.length, + 1, + "Extension is shown in new browser window." + ); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "about:addons" }, + async browser => { + await BrowserTestUtils.synthesizeMouseAtCenter( + "categories-box button[name=extension]", + {}, + browser + ); + const extensionToggle = await TestUtils.waitForCondition( + () => + browser.contentDocument.querySelector( + `addon-card[addon-id="${extension.id}"] moz-toggle` + ), + "Toggle button for extension is shown." + ); + + let promiseEvent = BrowserTestUtils.waitForEvent( + win, + "SidebarItemRemoved" + ); + extensionToggle.click(); + await promiseEvent; + await sidebar.updateComplete; + is(sidebar.extensionButtons.length, 0, "The extension is disabled."); + + promiseEvent = BrowserTestUtils.waitForEvent(win, "SidebarItemAdded"); + extensionToggle.click(); + await promiseEvent; + await sidebar.updateComplete; + is(sidebar.extensionButtons.length, 1, "The extension is enabled."); + } + ); + + await extension.unload(); + await sidebar.updateComplete; + is( + sidebar.extensionButtons.length, + 0, + "Extension is removed from the sidebar." + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_open_new_private_window_after_install() { + const extension = ExtensionTestUtils.loadExtension({ ...extData }); + await extension.startup(); + await extension.awaitMessage("sidebar"); + + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + const { document } = privateWin; + const sidebar = document.getElementById("sidebar-main"); + ok(sidebar, "Sidebar is shown."); + await TestUtils.waitForCondition( + () => sidebar.extensionButtons, + "Extensions container is shown." + ); + is( + sidebar.extensionButtons.length, + 0, + "Extension is hidden in private browser window." + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/components/sidebar/tests/browser/browser_history_sidebar.js b/browser/components/sidebar/tests/browser/browser_history_sidebar.js new file mode 100644 index 0000000000..a498eb4b8e --- /dev/null +++ b/browser/components/sidebar/tests/browser/browser_history_sidebar.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URLs = [ + "http://mochi.test:8888/browser/", + "https://www.example.com/", + "https://example.net/", + "https://example.org/", +]; + +const today = new Date(); +const yesterday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 1 +); +const dates = [today, yesterday]; + +let win; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ set: [["sidebar.revamp", true]] }); + const pageInfos = URLs.flatMap((url, i) => + dates.map(date => ({ + url, + title: `Example Domain ${i}`, + visits: [{ date }], + })) + ); + await PlacesUtils.history.insertMany(pageInfos); + win = await BrowserTestUtils.openNewBrowserWindow(); +}); + +registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + await PlacesUtils.history.clear(); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_history_cards_created() { + const { SidebarController } = win; + await SidebarController.show("viewHistorySidebar"); + const document = SidebarController.browser.contentDocument; + const component = document.querySelector("sidebar-history"); + await component.updateComplete; + const { lists } = component; + + Assert.equal(lists.length, dates.length, "There is a card for each day."); + for (const list of lists) { + Assert.equal( + list.tabItems.length, + URLs.length, + "Card shows the correct number of visits." + ); + } + + SidebarController.hide(); +}); + +add_task(async function test_history_search() { + const { SidebarController } = win; + await SidebarController.show("viewHistorySidebar"); + const { contentDocument: document, contentWindow } = + SidebarController.browser; + const component = document.querySelector("sidebar-history"); + await component.updateComplete; + const { searchTextbox } = component; + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, contentWindow); + EventUtils.sendString("Example Domain 1", contentWindow); + await BrowserTestUtils.waitForMutationCondition( + component.shadowRoot, + { childList: true, subtree: true }, + () => + component.lists.length === 1 && + component.shadowRoot.querySelector( + "moz-card[data-l10n-id=sidebar-search-results-header]" + ) + ); + await TestUtils.waitForCondition(() => { + const { rowEls } = component.lists[0]; + return rowEls.length === 1 && rowEls[0].mainEl.href === URLs[1]; + }, "There is one matching search result."); + + info("Input a bogus search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, contentWindow); + EventUtils.sendString("Bogus Query", contentWindow); + await TestUtils.waitForCondition(() => { + const tabList = component.lists[0]; + return tabList?.emptyState; + }, "There are no matching search results."); + + SidebarController.hide(); +}); diff --git a/browser/components/storybook/.storybook/main.js b/browser/components/storybook/.storybook/main.js index 0a5a55b851..276d3e06cf 100644 --- a/browser/components/storybook/.storybook/main.js +++ b/browser/components/storybook/.storybook/main.js @@ -23,6 +23,8 @@ module.exports = { `${projectRoot}/toolkit/content/widgets/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`, // about:logins components stories `${projectRoot}/browser/components/aboutlogins/content/components/**/*.stories.mjs`, + // Backup components stories + `${projectRoot}/browser/components/backup/content/**/*.stories.mjs`, // Reader View components stories `${projectRoot}/toolkit/components/reader/**/*.stories.mjs`, // Everything else diff --git a/browser/components/storybook/docs/README.other-widgets.stories.md b/browser/components/storybook/docs/README.other-widgets.stories.md index b2614d88b6..3c6f8d63f2 100644 --- a/browser/components/storybook/docs/README.other-widgets.stories.md +++ b/browser/components/storybook/docs/README.other-widgets.stories.md @@ -30,8 +30,8 @@ Please refer to the existing [UA widgets documentation](https://firefox-source-d ### How to use existing Mozilla Custom Elements -The existing Mozilla Custom Elements are automatically imported into all chrome privileged documents. -These existing elements do not need to be imported individually via `<script>` tag or by using `window.ensureCustomElements()` when in a privileged main process document. +The existing Mozilla Custom Elements are either [automatically imported](https://searchfox.org/mozilla-central/rev/d23849dd6d83edbe681d3b4828700256ea34a654/toolkit/content/customElements.js#853-878) into all chrome privileged documents, or are [lazy loaded](https://searchfox.org/mozilla-central/rev/d23849dd6d83edbe681d3b4828700256ea34a654/toolkit/content/customElements.js#789-809) and get automatically imported when first used. +In either case, these existing elements do not need to be imported individually via `<script>` tag. As long as you are working in a chrome privileged document, you will have access to the existing Mozilla Custom Elements. You can dynamically create one of the existing custom elements by using `document.createDocument("customElement)` or `document.createXULElement("customElement")` in the relevant JS file, or by using the custom element tag in the relevant XHTML document. For example, `document.createXULElement("checkbox")` creates an instance of [widgets/checkbox.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/checkbox.js) while using `<checkbox>` declares an instance in the XUL document. @@ -74,11 +74,10 @@ Just like with the UI widgets, [the `browser_all_files_referenced.js` will fail ### Using new domain-specific widgets -This is effectively the same as [using new design system components](#using-new-design-system-components). -You will need to import your widget into the relevant `html`/`xhtml` files via a `script` tag with `type="module"`: +This is effectively the same as [using new design system components](#using-new-design-system-components). In general you should be able to rely on these elements getting lazily loaded at the time of first use, similar to how existing custom elements are imported. + +Outside of chrome privileged documents you may need to import your widget into the relevant `html`/`xhtml` files via a `script` tag with `type="module"`: ```html <script type="module" src="chrome://browser/content/<domain-directory>/<your-widget>.mjs"></script> ``` - -Or use `window.ensureCustomElements("<your-widget>")` as previously stated in [the using new design system components section.](#using-new-design-system-components) diff --git a/browser/components/storybook/docs/README.reusable-widgets.stories.md b/browser/components/storybook/docs/README.reusable-widgets.stories.md index f26c18a2b0..003cbc36bf 100644 --- a/browser/components/storybook/docs/README.reusable-widgets.stories.md +++ b/browser/components/storybook/docs/README.reusable-widgets.stories.md @@ -16,6 +16,8 @@ re-rendering logic. All new components are being documented in Storybook in an effort to create a catalog that engineers and designers can use to see which components can be easily lifted off the shelf for use throughout Firefox. +If you want to see the progress over time of these new reusable components, we have a [Reusable Component Adoption chart](https://firefoxux.github.io/recomp-metrics/) that you should check out! + ## Designing new reusable widgets Widgets that live at the global level, "UI Widgets", should be created in collaboration with the Design System team. @@ -111,17 +113,16 @@ by updating [this array](https://searchfox.org/mozilla-central/rev/5c922d8b93b43 Once you've added a new component to `toolkit/content/widgets` and created `chrome://` URLs via `toolkit/content/jar.mn` you should be able to start using it -throughout Firefox. You can import the component into `html`/`xhtml` files via a +throughout Firefox. In most cases, you should be able to rely on your custom element getting lazy loaded at the time of first use, provided you are working in a privileged context where `customElements.js` is available. + +You can import the component directly into `html`/`xhtml` files via a `script` tag with `type="module"`: ```html <script type="module" src="chrome://global/content/elements/your-component-name.mjs"></script> ``` -If you are unable to import the new component in html, you can use [`ensureCustomElements()` in customElements.js](https://searchfox.org/mozilla-central/rev/31f5847a4494b3646edabbdd7ea39cb88509afe2/toolkit/content/customElements.js#865) in the relevant JS file. -For example, [we use `window.ensureCustomElements("moz-button-group")` in `browser-siteProtections.js`](https://searchfox.org/mozilla-central/rev/31f5847a4494b3646edabbdd7ea39cb88509afe2/browser/base/content/browser-siteProtections.js#1749). -**Note** you will need to add your new widget to [the switch in importCustomElementFromESModule](https://searchfox.org/mozilla-central/rev/85b4f7363292b272eb9b606e00de2c37a6be73f0/toolkit/content/customElements.js#845-859) for `ensureCustomElements()` to work as expected. -Once [Bug 1803810](https://bugzilla.mozilla.org/show_bug.cgi?id=1803810) lands, this process will be simplified: you won't need to use `ensureCustomElements()` and you will [add your widget to the appropriate array in customElements.js instead of the switch statement](https://searchfox.org/mozilla-central/rev/85b4f7363292b272eb9b606e00de2c37a6be73f0/toolkit/content/customElements.js#818-841). +**Note** you will need to add your new widget to [this array in customElements.js](https://searchfox.org/mozilla-central/rev/cde3d4a8d228491e8b7f1bd94c63bbe039850696/toolkit/content/customElements.js#791-810) to ensure it gets lazy loaded on creation. ## Common pitfalls diff --git a/browser/components/storybook/stories/fxview-tab-list.stories.mjs b/browser/components/storybook/stories/fxview-tab-list.stories.mjs index b18ad16e3a..888f9a567a 100644 --- a/browser/components/storybook/stories/fxview-tab-list.stories.mjs +++ b/browser/components/storybook/stories/fxview-tab-list.stories.mjs @@ -56,6 +56,7 @@ const Template = ({ .dateTimeFormat=${dateTimeFormat} .maxTabsLength=${maxTabsLength} .tabItems=${tabItems} + .updatesPaused=${false} @fxview-tab-list-secondary-action=${secondaryAction} @fxview-tab-list-primary-action=${primaryAction} > diff --git a/browser/components/storybook/stories/shopping-message-bar.stories.mjs b/browser/components/storybook/stories/shopping-message-bar.stories.mjs index 7bc0895fd0..61b99d4d8d 100644 --- a/browser/components/storybook/stories/shopping-message-bar.stories.mjs +++ b/browser/components/storybook/stories/shopping-message-bar.stories.mjs @@ -15,21 +15,19 @@ export default { component: "shopping-message-bar", argTypes: { type: { - control: { - type: "select", - options: [ - "stale", - "generic-error", - "not-enough-reviews", - "product-not-available", - "product-not-available-reported", - "thanks-for-reporting", - "analysis-in-progress", - "reanalysis-in-progress", - "page-not-supported", - "thank-you-for-feedback", - ], - }, + control: { type: "select" }, + options: [ + "stale", + "generic-error", + "not-enough-reviews", + "product-not-available", + "product-not-available-reported", + "thanks-for-reporting", + "analysis-in-progress", + "reanalysis-in-progress", + "page-not-supported", + "thank-you-for-feedback", + ], }, }, parameters: { diff --git a/browser/components/syncedtabs/TabListView.sys.mjs b/browser/components/syncedtabs/TabListView.sys.mjs index 70c98c4175..aa7ee439c3 100644 --- a/browser/components/syncedtabs/TabListView.sys.mjs +++ b/browser/components/syncedtabs/TabListView.sys.mjs @@ -5,6 +5,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); @@ -290,7 +291,7 @@ TabListView.prototype = { // Middle click on a client if (itemNode.classList.contains("client")) { - let where = getChromeWindow(this._window).whereToOpenLink(event); + let where = lazy.BrowserUtils.whereToOpenLink(event); if (where != "current") { this._openAllClientTabs(itemNode, where); } @@ -346,7 +347,7 @@ TabListView.prototype = { }, onOpenSelected(url, event) { - let where = getChromeWindow(this._window).whereToOpenLink(event); + let where = lazy.BrowserUtils.whereToOpenLink(event); this.props.onOpenTab(url, where, {}); }, diff --git a/browser/components/syncedtabs/sidebar.xhtml b/browser/components/syncedtabs/sidebar.xhtml index 7790620f94..b5701450bd 100644 --- a/browser/components/syncedtabs/sidebar.xhtml +++ b/browser/components/syncedtabs/sidebar.xhtml @@ -22,7 +22,6 @@ href="chrome://browser/skin/syncedtabs/sidebar.css" /> <link rel="localization" href="browser/syncedTabs.ftl" /> - <link rel="localization" href="toolkit/branding/accounts.ftl" /> <title data-l10n-id="synced-tabs-sidebar-title" /> </head> diff --git a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js index daf980e5fa..2728ba5155 100644 --- a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js +++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js @@ -91,28 +91,28 @@ function setupSyncedTabsStubs({ async function testClean() { sinon.restore(); await new Promise(resolve => { - window.SidebarUI.browser.contentWindow.addEventListener( + window.SidebarController.browser.contentWindow.addEventListener( "unload", function () { resolve(); }, { once: true } ); - SidebarUI.hide(); + SidebarController.hide(); }); } add_task(async function testSyncedTabsSidebarList() { - await SidebarUI.show("viewTabsSidebar"); + await SidebarController.show("viewTabsSidebar"); Assert.equal( - SidebarUI.currentID, + SidebarController.currentID, "viewTabsSidebar", "Sidebar should have SyncedTabs loaded" ); let syncedTabsDeckComponent = - SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + SidebarController.browser.contentWindow.syncedTabsDeckComponent; Assert.ok(syncedTabsDeckComponent, "component exists"); @@ -172,9 +172,9 @@ add_task(async function testSyncedTabsSidebarList() { add_task(testClean); add_task(async function testSyncedTabsSidebarFilteredList() { - await SidebarUI.show("viewTabsSidebar"); + await SidebarController.show("viewTabsSidebar"); let syncedTabsDeckComponent = - window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + window.SidebarController.browser.contentWindow.syncedTabsDeckComponent; Assert.ok(syncedTabsDeckComponent, "component exists"); @@ -244,9 +244,9 @@ add_task(async function testSyncedTabsSidebarFilteredList() { add_task(testClean); add_task(async function testSyncedTabsSidebarStatus() { - await SidebarUI.show("viewTabsSidebar"); + await SidebarController.show("viewTabsSidebar"); let syncedTabsDeckComponent = - window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + window.SidebarController.browser.contentWindow.syncedTabsDeckComponent; Assert.ok(syncedTabsDeckComponent, "component exists"); @@ -377,9 +377,9 @@ add_task(async function testSyncedTabsSidebarStatus() { add_task(testClean); add_task(async function testSyncedTabsSidebarContextMenu() { - await SidebarUI.show("viewTabsSidebar"); + await SidebarController.show("viewTabsSidebar"); let syncedTabsDeckComponent = - window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + window.SidebarController.browser.contentWindow.syncedTabsDeckComponent; Assert.ok(syncedTabsDeckComponent, "component exists"); @@ -547,7 +547,8 @@ async function testContextMenu( let chromeWindow = triggerElement.ownerGlobal.top; let rect = triggerElement.getBoundingClientRect(); - let contentRect = chromeWindow.SidebarUI.browser.getBoundingClientRect(); + let contentRect = + chromeWindow.SidebarController.browser.getBoundingClientRect(); // The offsets in `rect` are relative to the content window, but // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`, // which interprets the offsets relative to the containing *chrome* window. diff --git a/browser/components/tabpreview/tab-preview-panel.mjs b/browser/components/tabpreview/tab-preview-panel.mjs index 683b2c17ec..40874dbbf6 100644 --- a/browser/components/tabpreview/tab-preview-panel.mjs +++ b/browser/components/tabpreview/tab-preview-panel.mjs @@ -38,6 +38,12 @@ export default class TabPreviewPanel { "browser.tabs.cardPreview.showThumbnails", false ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_prefShowPidAndActiveness", + "browser.tabs.tooltipsShowPidAndActiveness", + false + ); this._timer = null; } @@ -132,6 +138,17 @@ export default class TabPreviewPanel { this._displayTitle; this._panel.querySelector(".tab-preview-uri").textContent = this._displayURI; + + if (this._prefShowPidAndActiveness) { + this._panel.querySelector(".tab-preview-pid").textContent = + this._displayPids; + this._panel.querySelector(".tab-preview-activeness").textContent = + this._displayActiveness; + } else { + this._panel.querySelector(".tab-preview-pid").textContent = ""; + this._panel.querySelector(".tab-preview-activeness").textContent = ""; + } + let thumbnailContainer = this._panel.querySelector( ".tab-preview-thumbnail-container" ); @@ -171,4 +188,18 @@ export default class TabPreviewPanel { } return this.getPrettyURI(this._tab.linkedBrowser.currentURI.spec); } + + get _displayPids() { + const pids = this._win.gBrowser.getTabPids(this._tab); + if (!pids.length) { + return ""; + } + + let pidLabel = pids.length > 1 ? "pids" : "pid"; + return `${pidLabel}: ${pids.join(", ")}`; + } + + get _displayActiveness() { + return this._tab.linkedBrowser.docShellIsActive ? "[A]" : ""; + } } diff --git a/browser/components/tabpreview/tabpreview.css b/browser/components/tabpreview/tabpreview.css index e978266e5d..ad84f685a0 100644 --- a/browser/components/tabpreview/tabpreview.css +++ b/browser/components/tabpreview/tabpreview.css @@ -1,12 +1,11 @@ /* 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/. */ + * 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-panel { --panel-width: 280px; --panel-padding: 0; - --panel-background: var(--tab-selected-bgcolor); - --panel-color: var(--tab-selected-textcolor); + pointer-events: none; } .tab-preview-text-container { @@ -14,19 +13,24 @@ } .tab-preview-title { - max-height: 3em; overflow: hidden; + -webkit-line-clamp: 2; font-weight: var(--font-weight-bold); } .tab-preview-uri { color: var(--text-color-deemphasized); - max-height: 1.5em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.tab-preview-pid-activeness { + color: var(--text-color-deemphasized); + display: flex; + justify-content: space-between; +} + .tab-preview-thumbnail-container { border-top: 1px solid var(--panel-border-color); &:empty { diff --git a/browser/components/tests/browser/browser.toml b/browser/components/tests/browser/browser.toml index c754191b8b..366842b04d 100644 --- a/browser/components/tests/browser/browser.toml +++ b/browser/components/tests/browser/browser.toml @@ -4,6 +4,9 @@ support-files = [ "../../../../dom/security/test/csp/dummy.pdf", ] +["browser_browserGlue_os_auth.js"] +skip-if = ["os == 'linux'"] + ["browser_browserGlue_showModal_trigger.js"] ["browser_browserGlue_telemetry.js"] diff --git a/browser/components/tests/browser/browser_browserGlue_os_auth.js b/browser/components/tests/browser/browser_browserGlue_os_auth.js new file mode 100644 index 0000000000..9f17a00d03 --- /dev/null +++ b/browser/components/tests/browser/browser_browserGlue_os_auth.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +); + +// Check whether os auth is disabled by default on a new profile in Beta and Release. +add_task(async function test_creditCards_os_auth_disabled_for_new_profile() { + Assert.equal( + FormAutofillUtils.getOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ), + AppConstants.NIGHTLY_BUILD, + "OS Auth should be disabled for credit cards by default for a new profile." + ); + + Assert.equal( + LoginHelper.getOSAuthEnabled(LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF), + AppConstants.NIGHTLY_BUILD, + "OS Auth should be disabled for passwords by default for a new profile." + ); +}); diff --git a/browser/components/tests/browser/browser_browserGlue_upgradeDialog_trigger.js b/browser/components/tests/browser/browser_browserGlue_upgradeDialog_trigger.js index 88004525c8..f7b3e4c06f 100644 --- a/browser/components/tests/browser/browser_browserGlue_upgradeDialog_trigger.js +++ b/browser/components/tests/browser/browser_browserGlue_upgradeDialog_trigger.js @@ -122,83 +122,3 @@ add_task(async function show_major_upgrade() { defaultPrefs.setBoolPref(pref, orig); await cleanupUpgrade(); }); - -add_task(async function test_mr2022_upgradeDialogEnabled() { - const FALLBACK_PREF = "browser.startup.upgradeDialog.enabled"; - - async function runMajorReleaseTest( - { onboarding = undefined, enabled = undefined, fallbackPref = undefined }, - expected - ) { - info("Testing upgradeDialog with:"); - info(` majorRelease2022.onboarding=${onboarding}`); - info(` upgradeDialog.enabled=${enabled}`); - info(` ${FALLBACK_PREF}=${fallbackPref}`); - - let mr2022Cleanup = async () => {}; - let upgradeDialogCleanup = async () => {}; - - if (typeof onboarding !== "undefined") { - mr2022Cleanup = await ExperimentFakes.enrollWithFeatureConfig({ - featureId: "majorRelease2022", - value: { onboarding }, - }); - } - - if (typeof enabled !== "undefined") { - upgradeDialogCleanup = await ExperimentFakes.enrollWithFeatureConfig({ - featureId: "upgradeDialog", - value: { enabled }, - }); - } - - if (typeof fallbackPref !== "undefined") { - await SpecialPowers.pushPrefEnv({ - set: [[FALLBACK_PREF, fallbackPref]], - }); - } - - const cleanupForcedUpgrade = await forceMajorUpgrade(); - - try { - await BROWSER_GLUE._maybeShowDefaultBrowserPrompt(); - AssertEvents(`Upgrade dialog ${expected ? "shown" : "not shown"}`, [ - "trigger", - "reason", - expected ? "satisfied" : "disabled", - ]); - - if (expected) { - const [win] = await TestUtils.topicObserved("subdialog-loaded"); - win.close(); - await BrowserTestUtils.removeTab(gBrowser.selectedTab); - } - } finally { - await cleanupForcedUpgrade(); - if (typeof fallbackPref !== "undefined") { - await SpecialPowers.popPrefEnv(); - } - await upgradeDialogCleanup(); - await mr2022Cleanup(); - } - } - - await runMajorReleaseTest({ onboarding: true }, true); - await runMajorReleaseTest({ onboarding: true, enabled: false }, true); - await runMajorReleaseTest({ onboarding: true, fallbackPref: false }, true); - - await runMajorReleaseTest({ onboarding: false }, false); - await runMajorReleaseTest({ onboarding: false, enabled: true }, false); - await runMajorReleaseTest({ onboarding: false, fallbackPref: true }, false); - - await runMajorReleaseTest({ enabled: true }, true); - await runMajorReleaseTest({ enabled: true, fallbackPref: false }, true); - await runMajorReleaseTest({ fallbackPref: true }, true); - - await runMajorReleaseTest({ enabled: false }, false); - await runMajorReleaseTest({ enabled: false, fallbackPref: true }, false); - await runMajorReleaseTest({ fallbackPref: false }, false); - - // Test the default configuration. - await runMajorReleaseTest({}, false); -}); diff --git a/browser/components/tests/unit/test_browserGlue_migration_osauth.js b/browser/components/tests/unit/test_browserGlue_migration_osauth.js new file mode 100644 index 0000000000..5676ea2fa9 --- /dev/null +++ b/browser/components/tests/unit/test_browserGlue_migration_osauth.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TOPIC_BROWSERGLUE_TEST = "browser-glue-test"; +const TOPICDATA_BROWSERGLUE_TEST = "force-ui-migration"; +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver +); +const UI_VERSION = 147; + +const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); +const { FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +); + +const CC_OLD_PREF = "extensions.formautofill.reauth.enabled"; +const CC_TYPO_PREF = "extensions.formautofill.creditcards.reauth.optout"; +const CC_NEW_PREF = FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF; + +const PASSWORDS_OLD_PREF = "signon.management.page.os-auth.enabled"; +const PASSWORDS_NEW_PREF = LoginHelper.OS_AUTH_FOR_PASSWORDS_PREF; + +function clearPrefs() { + Services.prefs.clearUserPref("browser.migration.version"); + Services.prefs.clearUserPref(CC_OLD_PREF); + Services.prefs.clearUserPref(CC_TYPO_PREF); + Services.prefs.clearUserPref(CC_NEW_PREF); + Services.prefs.clearUserPref(PASSWORDS_OLD_PREF); + Services.prefs.clearUserPref(PASSWORDS_NEW_PREF); + Services.prefs.clearUserPref("browser.startup.homepage_override.mstone"); +} + +function simulateUIMigration() { + gBrowserGlue.observe( + null, + TOPIC_BROWSERGLUE_TEST, + TOPICDATA_BROWSERGLUE_TEST + ); +} + +add_task(async function setup() { + registerCleanupFunction(clearPrefs); +}); + +add_task(async function test_pref_migration_old_pref_os_auth_disabled() { + Services.prefs.setIntPref("browser.migration.version", UI_VERSION - 1); + Services.prefs.setBoolPref(CC_OLD_PREF, false); + Services.prefs.setBoolPref(PASSWORDS_OLD_PREF, false); + + simulateUIMigration(); + + Assert.ok( + !FormAutofillUtils.getOSAuthEnabled(CC_NEW_PREF), + "OS Auth should be disabled for credit cards since it was disabled before migration." + ); + Assert.ok( + !LoginHelper.getOSAuthEnabled(PASSWORDS_NEW_PREF), + "OS Auth should be disabled for passwords since it was disabled before migration." + ); + clearPrefs(); +}); + +add_task(async function test_pref_migration_old_pref_os_auth_enabled() { + Services.prefs.setIntPref("browser.migration.version", UI_VERSION - 1); + Services.prefs.setBoolPref(CC_OLD_PREF, true); + Services.prefs.setBoolPref(PASSWORDS_OLD_PREF, true); + + simulateUIMigration(); + + Assert.ok( + FormAutofillUtils.getOSAuthEnabled(CC_NEW_PREF), + "OS Auth should be enabled for credit cards since it was enabled before migration." + ); + Assert.ok( + LoginHelper.getOSAuthEnabled(PASSWORDS_NEW_PREF), + "OS Auth should be enabled for passwords since it was enabled before migration." + ); + clearPrefs(); +}); + +add_task( + async function test_creditCards_pref_migration_typo_pref_os_auth_disabled() { + Services.prefs.setIntPref("browser.migration.version", UI_VERSION - 1); + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + "127.0" + ); + FormAutofillUtils.setOSAuthEnabled(CC_TYPO_PREF, false); + + simulateUIMigration(); + + Assert.ok( + !FormAutofillUtils.getOSAuthEnabled(CC_NEW_PREF), + "OS Auth should be disabled for credit cards since it was disabled before migration." + ); + clearPrefs(); + } +); + +add_task( + async function test_creditCards_pref_migration_typo_pref_os_auth_enabled() { + Services.prefs.setIntPref("browser.migration.version", UI_VERSION - 1); + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + "127.0" + ); + FormAutofillUtils.setOSAuthEnabled(CC_TYPO_PREF, true); + + simulateUIMigration(); + + Assert.ok( + FormAutofillUtils.getOSAuthEnabled(CC_NEW_PREF), + "OS Auth should be enabled for credit cards since it was enabled before migration." + ); + clearPrefs(); + } +); + +add_task( + async function test_creditCards_pref_migration_real_pref_os_auth_disabled() { + Services.prefs.setIntPref("browser.migration.version", UI_VERSION - 1); + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + "127.0" + ); + FormAutofillUtils.setOSAuthEnabled(CC_NEW_PREF, false); + + simulateUIMigration(); + + Assert.ok( + !FormAutofillUtils.getOSAuthEnabled(CC_NEW_PREF), + "OS Auth should be disabled for credit cards since it was disabled before migration." + ); + clearPrefs(); + } +); + +add_task( + async function test_creditCards_pref_migration_real_pref_os_auth_enabled() { + Services.prefs.setIntPref("browser.migration.version", UI_VERSION - 1); + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + "127.0" + ); + FormAutofillUtils.setOSAuthEnabled(CC_NEW_PREF, true); + + simulateUIMigration(); + + Assert.ok( + FormAutofillUtils.getOSAuthEnabled(CC_NEW_PREF), + "OS Auth should be enabled for credit cards since it was enabled before migration." + ); + clearPrefs(); + } +); diff --git a/browser/components/tests/unit/xpcshell.toml b/browser/components/tests/unit/xpcshell.toml index 1b566698ee..b552fd2fa8 100644 --- a/browser/components/tests/unit/xpcshell.toml +++ b/browser/components/tests/unit/xpcshell.toml @@ -10,6 +10,9 @@ support-files = ["distribution.ini"] ["test_browserGlue_migration_no_errors.js"] +["test_browserGlue_migration_osauth.js"] +skip-if = ["nightly_build", "os == 'linux'"] + ["test_browserGlue_migration_places_xulstore.js"] ["test_browserGlue_migration_remove_pref.js"] diff --git a/browser/components/topsites/TopSites.sys.mjs b/browser/components/topsites/TopSites.sys.mjs new file mode 100644 index 0000000000..736a079f34 --- /dev/null +++ b/browser/components/topsites/TopSites.sys.mjs @@ -0,0 +1,2039 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "resource://activity-stream/common/Actions.mjs"; +import { TippyTopProvider } from "resource://activity-stream/lib/TippyTopProvider.sys.mjs"; +import { + insertPinned, + TOP_SITES_MAX_SITES_PER_ROW, +} from "resource://activity-stream/common/Reducers.sys.mjs"; +import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs"; +import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; +import { getDefaultOptions } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; + +import { + CUSTOM_SEARCH_SHORTCUTS, + SEARCH_SHORTCUTS_EXPERIMENT, + SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF, + SEARCH_SHORTCUTS_HAVE_PINNED_PREF, + checkHasSearchEngine, + getSearchProvider, + getSearchFormURL, +} from "resource://activity-stream/lib/SearchShortcuts.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + LinksCache: "resource://activity-stream/lib/LinksCache.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", + Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("TopSites"); +}); + +// `contextId` is a unique identifier used by Contextual Services +const CONTEXT_ID_PREF = "browser.contextual-services.contextId"; +ChromeUtils.defineLazyGetter(lazy, "contextId", () => { + let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null); + if (!_contextId) { + _contextId = String(Services.uuid.generateUUID()); + Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId); + } + return _contextId; +}); + +const DEFAULT_SITES_PREF = "default.sites"; +const SHOWN_ON_NEWTAB_PREF = "feeds.topsites"; +export const DEFAULT_TOP_SITES = []; +const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages) +const MIN_FAVICON_SIZE = 96; +const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"]; +const PINNED_FAVICON_PROPS_TO_MIGRATE = [ + "favicon", + "faviconRef", + "faviconSize", +]; +const SECTION_ID = "topsites"; +const ROWS_PREF = "topSitesRows"; +const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; +// The default total number of sponsored top sites to fetch from Contile +// and Pocket. +const MAX_NUM_SPONSORED = 2; +// Nimbus variable for the total number of sponsored top sites including +// both Contile and Pocket sources. +// The default will be `MAX_NUM_SPONSORED` if this variable is unspecified. +const NIMBUS_VARIABLE_MAX_SPONSORED = "topSitesMaxSponsored"; +// Nimbus variable to allow more than two sponsored tiles from Contile to be +//considered for Top Sites. +const NIMBUS_VARIABLE_ADDITIONAL_TILES = + "topSitesUseAdditionalTilesFromContile"; +// Nimbus variable to enable the SOV feature for sponsored tiles. +const NIMBUS_VARIABLE_CONTILE_SOV_ENABLED = "topSitesContileSovEnabled"; +// Nimbu variable for the total number of sponsor topsite that come from Contile +// The default will be `CONTILE_MAX_NUM_SPONSORED` if variable is unspecified. +const NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED = "topSitesContileMaxSponsored"; + +// Search experiment stuff +const FILTER_DEFAULT_SEARCH_PREF = "improvesearch.noDefaultSearchTile"; +const SEARCH_FILTERS = [ + "google", + "search.yahoo", + "yahoo", + "bing", + "ask", + "duckduckgo", +]; + +const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting"; +const DEFAULT_SITES_OVERRIDE_PREF = + "browser.newtabpage.activity-stream.default.sites"; +const DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH = "browser.topsites.experiment."; + +// Mozilla Tiles Service (Contile) prefs +// Nimbus variable for the Contile integration. It falls back to the pref: +// `browser.topsites.contile.enabled`. +const NIMBUS_VARIABLE_CONTILE_ENABLED = "topSitesContileEnabled"; +const NIMBUS_VARIABLE_CONTILE_POSITIONS = "contileTopsitesPositions"; +const CONTILE_ENDPOINT_PREF = "browser.topsites.contile.endpoint"; +const CONTILE_UPDATE_INTERVAL = 15 * 60 * 1000; // 15 minutes +// The maximum number of sponsored top sites to fetch from Contile. +const CONTILE_MAX_NUM_SPONSORED = 2; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles"; +const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor"; +const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch"; + +// Partners of sponsored tiles. +const SPONSORED_TILE_PARTNER_AMP = "amp"; +const SPONSORED_TILE_PARTNER_MOZ_SALES = "moz-sales"; +const SPONSORED_TILE_PARTNERS = new Set([ + SPONSORED_TILE_PARTNER_AMP, + SPONSORED_TILE_PARTNER_MOZ_SALES, +]); + +const DISPLAY_FAIL_REASON_OVERSOLD = "oversold"; +const DISPLAY_FAIL_REASON_DISMISSED = "dismissed"; +const DISPLAY_FAIL_REASON_UNRESOLVED = "unresolved"; + +function getShortURLForCurrentSearch() { + const url = shortURL({ url: Services.search.defaultEngine.searchForm }); + return url; +} + +class TopSitesTelemetry { + constructor() { + this.allSponsoredTiles = {}; + this.sponsoredTilesConfigured = 0; + } + + _tileProviderForTiles(tiles) { + // Assumption: the list of tiles is from a single provider + return tiles && tiles.length ? this._tileProvider(tiles[0]) : null; + } + + _tileProvider(tile) { + return tile.partner || SPONSORED_TILE_PARTNER_AMP; + } + + _buildPropertyKey(tile) { + let provider = this._tileProvider(tile); + return provider + shortURL(tile); + } + + // Returns an array of strings indicating the property name (based on the + // provider and brand) of tiles that have been filtered e.g. ["moz-salesbrand1"] + // currentTiles: The list of tiles remaining and may be displayed in new tab. + // this.allSponsoredTiles: The original list of tiles set via setTiles prior to any filtering + // The returned list indicated the difference between these two lists (excluding any previously filtered tiles). + _getFilteredTiles(currentTiles) { + let notPreviouslyFilteredTiles = Object.assign( + {}, + ...Object.entries(this.allSponsoredTiles) + .filter( + ([, v]) => + v.display_fail_reason === null || + v.display_fail_reason === undefined + ) + .map(([k, v]) => ({ [k]: v })) + ); + + // Get the property names of the newly filtered list. + let remainingTiles = currentTiles.map(el => { + return this._buildPropertyKey(el); + }); + + // Get the property names of the tiles that were filtered. + let tilesToUpdate = Object.keys(notPreviouslyFilteredTiles).filter( + element => !remainingTiles.includes(element) + ); + return tilesToUpdate; + } + + setSponsoredTilesConfigured() { + const maxSponsored = + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_MAX_SPONSORED + ) ?? MAX_NUM_SPONSORED; + + this.sponsoredTilesConfigured = maxSponsored; + Glean.topsites.sponsoredTilesConfigured.set(maxSponsored); + } + + clearTilesForProvider(provider) { + Object.entries(this.allSponsoredTiles) + .filter(([k]) => k.startsWith(provider)) + .map(([k]) => delete this.allSponsoredTiles[k]); + } + + _getAdvertiser(tile) { + let label = tile.label || null; + let title = tile.title || null; + + return label ?? title ?? shortURL(tile); + } + + setTiles(tiles) { + // Assumption: the list of tiles is from a single provider, + // should be called once per tile source. + if (tiles && tiles.length) { + let tile_provider = this._tileProviderForTiles(tiles); + this.clearTilesForProvider(tile_provider); + + for (let sponsoredTile of tiles) { + this.allSponsoredTiles[this._buildPropertyKey(sponsoredTile)] = { + advertiser: this._getAdvertiser(sponsoredTile).toLowerCase(), + provider: tile_provider, + display_position: null, + display_fail_reason: null, + }; + } + } + } + + _setDisplayFailReason(filteredTiles, reason) { + for (let tile of filteredTiles) { + if (tile in this.allSponsoredTiles) { + let tileToUpdate = this.allSponsoredTiles[tile]; + tileToUpdate.display_position = null; + tileToUpdate.display_fail_reason = reason; + } + } + } + + determineFilteredTilesAndSetToOversold(nonOversoldTiles) { + let filteredTiles = this._getFilteredTiles(nonOversoldTiles); + this._setDisplayFailReason(filteredTiles, DISPLAY_FAIL_REASON_OVERSOLD); + } + + determineFilteredTilesAndSetToDismissed(nonDismissedTiles) { + let filteredTiles = this._getFilteredTiles(nonDismissedTiles); + this._setDisplayFailReason(filteredTiles, DISPLAY_FAIL_REASON_DISMISSED); + } + + _setTilePositions(currentTiles) { + // This function performs many loops over a small dataset. The size of + // dataset is limited by the number of sponsored tiles displayed on + // the newtab instance. + if (this.allSponsoredTiles) { + let tilePositionsAssigned = []; + // processing the currentTiles parameter, assigns a position to the + // corresponding property in this.allSponsoredTiles + currentTiles.forEach(item => { + let tile = this.allSponsoredTiles[this._buildPropertyKey(item)]; + if ( + tile && + (tile.display_fail_reason === undefined || + tile.display_fail_reason === null) + ) { + tile.display_position = item.sponsored_position; + // Track assigned tile slots. + tilePositionsAssigned.push(item.sponsored_position); + } + }); + + // Need to check if any objects in this.allSponsoredTiles do not + // have either a display_fail_reason or a display_position set. + // This can happen if the tiles list was updated before the + // metric is written to Glean. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1877197 + let tilesMissingPosition = []; + Object.keys(this.allSponsoredTiles).forEach(property => { + let tile = this.allSponsoredTiles[property]; + if (!tile.display_fail_reason && !tile.display_position) { + tilesMissingPosition.push(property); + } + }); + + if (tilesMissingPosition.length) { + // Determine if any available slots exist based on max number of tiles + // and the list of tiles already used and assign to a tile with missing + // value. + for (let i = 1; i <= this.sponsoredTilesConfigured; i++) { + if (!tilePositionsAssigned.includes(i)) { + let tileProperty = tilesMissingPosition.shift(); + this.allSponsoredTiles[tileProperty].display_position = i; + } + } + } + + // At this point we might still have a few unresolved states. These + // rows will be tagged with a display_fail_reason `unresolved`. + this._detectErrorConditionAndSetUnresolved(); + } + } + + // Checks the data for inconsistent state and updates the display_fail_reason + _detectErrorConditionAndSetUnresolved() { + Object.keys(this.allSponsoredTiles).forEach(property => { + let tile = this.allSponsoredTiles[property]; + if ( + (!tile.display_fail_reason && !tile.display_position) || + (tile.display_fail_reason && tile.display_position) + ) { + tile.display_position = null; + tile.display_fail_reason = DISPLAY_FAIL_REASON_UNRESOLVED; + } + }); + } + + finalizeNewtabPingFields(currentTiles) { + this._setTilePositions(currentTiles); + Glean.topsites.sponsoredTilesReceived.set( + JSON.stringify({ + sponsoredTilesReceived: Object.values(this.allSponsoredTiles), + }) + ); + } +} + +export class ContileIntegration { + constructor(topSitesFeed) { + this._topSitesFeed = topSitesFeed; + this._lastPeriodicUpdate = 0; + this._sites = []; + // The Share-of-Voice object managed by Shepherd and sent via Contile. + this._sov = null; + } + + get sites() { + return this._sites; + } + + get sov() { + return this._sov; + } + + periodicUpdate() { + let now = Date.now(); + if (now - this._lastPeriodicUpdate >= CONTILE_UPDATE_INTERVAL) { + this._lastPeriodicUpdate = now; + this.refresh(); + } + } + + async refresh() { + let updateDefaultSites = await this._fetchSites(); + await this._topSitesFeed.allocatePositions(); + if (updateDefaultSites) { + this._topSitesFeed._readDefaults(); + } + } + + /** + * Clear Contile Cache Prefs. + */ + _resetContileCachePrefs() { + Services.prefs.clearUserPref(CONTILE_CACHE_PREF); + Services.prefs.clearUserPref(CONTILE_CACHE_LAST_FETCH_PREF); + Services.prefs.clearUserPref(CONTILE_CACHE_VALID_FOR_PREF); + } + + /** + * Filter the tiles whose sponsor is on the Top Sites sponsor blocklist. + * + * @param {Array} tiles + * An array of the tile objects + */ + _filterBlockedSponsors(tiles) { + const blocklist = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + return tiles.filter(tile => !blocklist.includes(shortURL(tile))); + } + + /** + * Calculate the time Contile response is valid for based on cache-control header + * + * @param {string} cacheHeader + * string value of the Contile resposne cache-control header + */ + _extractCacheValidFor(cacheHeader) { + if (!cacheHeader) { + lazy.log.warn("Contile response cache control header is empty"); + return 0; + } + const [, staleIfError] = cacheHeader.match(/stale-if-error=\s*([0-9]+)/i); + const [, maxAge] = cacheHeader.match(/max-age=\s*([0-9]+)/i); + const validFor = + Number.parseInt(staleIfError, 10) + Number.parseInt(maxAge, 10); + return isNaN(validFor) ? 0 : validFor; + } + + /** + * Load Tiles from Contile Cache Prefs + */ + _loadTilesFromCache() { + lazy.log.info("Contile client is trying to load tiles from local cache."); + const now = Math.round(Date.now() / 1000); + const lastFetch = Services.prefs.getIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + 0 + ); + const validFor = Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_PREF, 0); + this._topSitesFeed._telemetryUtility.setSponsoredTilesConfigured(); + if (now <= lastFetch + validFor) { + try { + let cachedTiles = JSON.parse( + Services.prefs.getStringPref(CONTILE_CACHE_PREF) + ); + this._topSitesFeed._telemetryUtility.setTiles(cachedTiles); + cachedTiles = this._filterBlockedSponsors(cachedTiles); + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToDismissed( + cachedTiles + ); + this._sites = cachedTiles; + lazy.log.info("Local cache loaded."); + return true; + } catch (error) { + lazy.log.warn(`Failed to load tiles from local cache: ${error}.`); + return false; + } + } + + return false; + } + + /** + * Determine number of Tiles to get from Contile + */ + _getMaxNumFromContile() { + return ( + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED + ) ?? CONTILE_MAX_NUM_SPONSORED + ); + } + + async _fetchSites() { + if ( + !lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_CONTILE_ENABLED + ) || + !this._topSitesFeed.store.getState().Prefs.values[SHOW_SPONSORED_PREF] + ) { + if (this._sites.length) { + this._sites = []; + return true; + } + return false; + } + try { + let url = Services.prefs.getStringPref(CONTILE_ENDPOINT_PREF); + const response = await this._topSitesFeed.fetch(url, { + credentials: "omit", + }); + if (!response.ok) { + lazy.log.warn( + `Contile endpoint returned unexpected status: ${response.status}` + ); + if (response.status === 304 || response.status >= 500) { + return this._loadTilesFromCache(); + } + } + + const lastFetch = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, lastFetch); + this._topSitesFeed._telemetryUtility.setSponsoredTilesConfigured(); + + // Contile returns 204 indicating there is no content at the moment. + // If this happens, it will clear `this._sites` reset the cached tiles + // to an empty array. + if (response.status === 204) { + this._topSitesFeed._telemetryUtility.clearTilesForProvider( + SPONSORED_TILE_PARTNER_AMP + ); + if (this._sites.length) { + this._sites = []; + Services.prefs.setStringPref( + CONTILE_CACHE_PREF, + JSON.stringify(this._sites) + ); + return true; + } + return false; + } + const body = await response.json(); + + if (body?.sov) { + this._sov = JSON.parse(atob(body.sov)); + } + if (body?.tiles && Array.isArray(body.tiles)) { + const useAdditionalTiles = lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_ADDITIONAL_TILES + ); + + const maxNumFromContile = this._getMaxNumFromContile(); + + let { tiles } = body; + this._topSitesFeed._telemetryUtility.setTiles(tiles); + if ( + useAdditionalTiles !== undefined && + !useAdditionalTiles && + tiles.length > maxNumFromContile + ) { + tiles.length = maxNumFromContile; + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToOversold( + tiles + ); + } + tiles = this._filterBlockedSponsors(tiles); + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToDismissed( + tiles + ); + if (tiles.length > maxNumFromContile) { + lazy.log.info("Remove unused links from Contile"); + tiles.length = maxNumFromContile; + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToOversold( + tiles + ); + } + this._sites = tiles; + Services.prefs.setStringPref( + CONTILE_CACHE_PREF, + JSON.stringify(this._sites) + ); + Services.prefs.setIntPref( + CONTILE_CACHE_VALID_FOR_PREF, + this._extractCacheValidFor( + response.headers.get("cache-control") || + response.headers.get("Cache-Control") + ) + ); + + return true; + } + } catch (error) { + lazy.log.warn( + `Failed to fetch data from Contile server: ${error.message}` + ); + return this._loadTilesFromCache(); + } + return false; + } +} + +class _TopSites { + constructor() { + this._telemetryUtility = new TopSitesTelemetry(); + this._contile = new ContileIntegration(this); + this._tippyTopProvider = new TippyTopProvider(); + ChromeUtils.defineLazyGetter( + this, + "_currentSearchHostname", + getShortURLForCurrentSearch + ); + this.dedupe = new Dedupe(this._dedupeKey); + this.frecentCache = new lazy.LinksCache( + lazy.NewTabUtils.activityStreamLinks, + "getTopSites", + CACHED_LINK_PROPS_TO_MIGRATE, + (oldOptions, newOptions) => + // Refresh if no old options or requesting more items + !(oldOptions.numItems >= newOptions.numItems) + ); + this.pinnedCache = new lazy.LinksCache( + lazy.NewTabUtils.pinnedLinks, + "links", + [...CACHED_LINK_PROPS_TO_MIGRATE, ...PINNED_FAVICON_PROPS_TO_MIGRATE] + ); + lazy.PageThumbs.addExpirationFilter(this); + this._nimbusChangeListener = this._nimbusChangeListener.bind(this); + } + + _nimbusChangeListener(event, reason) { + // The Nimbus API current doesn't specify the changed variable(s) in the + // listener callback, so we have to refresh unconditionally on every change + // of the `newtab` feature. It should be a manageable overhead given the + // current update cadence (6 hours) of Nimbus. + // + // Skip the experiment and rollout loading reasons since this feature has + // `isEarlyStartup` enabled, the feature variables are already available + // before the experiment or rollout loads. + if ( + !["feature-experiment-loaded", "feature-rollout-loaded"].includes(reason) + ) { + this._contile.refresh(); + } + } + + init() { + // If the feed was previously disabled PREFS_INITIAL_VALUES was never received + this._readDefaults({ isStartup: true }); + this._storage = this.store.dbStorage.getDbTable("sectionPrefs"); + this._contile.refresh(); + Services.obs.addObserver(this, "browser-search-engine-modified"); + Services.obs.addObserver(this, "browser-region-updated"); + Services.prefs.addObserver(REMOTE_SETTING_DEFAULTS_PREF, this); + Services.prefs.addObserver(DEFAULT_SITES_OVERRIDE_PREF, this); + Services.prefs.addObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this); + lazy.NimbusFeatures.newtab.onUpdate(this._nimbusChangeListener); + } + + uninit() { + lazy.PageThumbs.removeExpirationFilter(this); + Services.obs.removeObserver(this, "browser-search-engine-modified"); + Services.obs.removeObserver(this, "browser-region-updated"); + Services.prefs.removeObserver(REMOTE_SETTING_DEFAULTS_PREF, this); + Services.prefs.removeObserver(DEFAULT_SITES_OVERRIDE_PREF, this); + Services.prefs.removeObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this); + lazy.NimbusFeatures.newtab.offUpdate(this._nimbusChangeListener); + } + + observe(subj, topic, data) { + switch (topic) { + case "browser-search-engine-modified": + // We should update the current top sites if the search engine has been changed since + // the search engine that gets filtered out of top sites has changed. + // We also need to drop search shortcuts when their engine gets removed / hidden. + if ( + data === "engine-default" && + this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] + ) { + delete this._currentSearchHostname; + this._currentSearchHostname = getShortURLForCurrentSearch(); + } + this.refresh({ broadcast: true }); + break; + case "browser-region-updated": + this._readDefaults(); + break; + case "nsPref:changed": + if ( + data === REMOTE_SETTING_DEFAULTS_PREF || + data === DEFAULT_SITES_OVERRIDE_PREF || + data.startsWith(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH) + ) { + this._readDefaults(); + } + break; + } + } + + _dedupeKey(site) { + return site && site.hostname; + } + + /** + * _readDefaults - sets DEFAULT_TOP_SITES + */ + async _readDefaults({ isStartup = false } = {}) { + this._useRemoteSetting = false; + + if (!Services.prefs.getBoolPref(REMOTE_SETTING_DEFAULTS_PREF)) { + this.refreshDefaults( + this.store.getState().Prefs.values[DEFAULT_SITES_PREF], + { isStartup } + ); + return; + } + + // Try using default top sites from enterprise policies or tests. The pref + // is locked when set via enterprise policy. Tests have no default sites + // unless they set them via this pref. + if ( + Services.prefs.prefIsLocked(DEFAULT_SITES_OVERRIDE_PREF) || + Cu.isInAutomation + ) { + let sites = Services.prefs.getStringPref(DEFAULT_SITES_OVERRIDE_PREF, ""); + this.refreshDefaults(sites, { isStartup }); + return; + } + + // Clear out the array of any previous defaults. + DEFAULT_TOP_SITES.length = 0; + + // Read defaults from contile. + const contileEnabled = lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_CONTILE_ENABLED + ); + + // Keep the number of positions in the array in sync with CONTILE_MAX_NUM_SPONSORED. + // sponsored_position is a 1-based index, and contilePositions is a 0-based index, + // so we need to add 1 to each of these. + // Also currently this does not work with SOV. + let contilePositions = lazy.NimbusFeatures.pocketNewtab + .getVariable(NIMBUS_VARIABLE_CONTILE_POSITIONS) + ?.split(",") + .map(item => parseInt(item, 10) + 1) + .filter(item => !Number.isNaN(item)); + if (!contilePositions || contilePositions.length === 0) { + contilePositions = [1, 2]; + } + + let hasContileTiles = false; + if (contileEnabled) { + let contilePositionIndex = 0; + // We need to loop through potential spocs and set their positions. + // If we run out of spocs or positions, we stop. + // First, we need to know which array is shortest. This is our exit condition. + const minLength = Math.min( + contilePositions.length, + this._contile.sites.length + ); + // Loop until we run out of spocs or positions. + for (let i = 0; i < minLength; i++) { + let site = this._contile.sites[i]; + let hostname = shortURL(site); + let link = { + isDefault: true, + url: site.url, + hostname, + sendAttributionRequest: false, + label: site.name, + show_sponsored_label: hostname !== "yandex", + sponsored_position: contilePositions[contilePositionIndex++], + sponsored_click_url: site.click_url, + sponsored_impression_url: site.impression_url, + sponsored_tile_id: site.id, + partner: SPONSORED_TILE_PARTNER_AMP, + }; + if (site.image_url && site.image_size >= MIN_FAVICON_SIZE) { + // Only use the image from Contile if it's hi-res, otherwise, fallback + // to the built-in favicons. + link.favicon = site.image_url; + link.faviconSize = site.image_size; + } + DEFAULT_TOP_SITES.push(link); + } + hasContileTiles = contilePositionIndex > 0; + //This is to catch where we receive 3 tiles but reduce to 2 early in the filtering, before blocked list applied. + this._telemetryUtility.determineFilteredTilesAndSetToOversold( + DEFAULT_TOP_SITES + ); + } + + // Read defaults from remote settings. + this._useRemoteSetting = true; + let remoteSettingData = await this._getRemoteConfig(); + + const sponsoredBlocklist = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + + for (let siteData of remoteSettingData) { + let hostname = shortURL(siteData); + // Drop default sites when Contile already provided a sponsored one with + // the same host name. + if ( + contileEnabled && + DEFAULT_TOP_SITES.findIndex(site => site.hostname === hostname) > -1 + ) { + continue; + } + // Also drop those sponsored sites that were blocked by the user before + // with the same hostname. + if ( + siteData.sponsored_position && + sponsoredBlocklist.includes(hostname) + ) { + continue; + } + let link = { + isDefault: true, + url: siteData.url, + hostname, + sendAttributionRequest: !!siteData.send_attribution_request, + }; + if (siteData.url_urlbar_override) { + link.url_urlbar = siteData.url_urlbar_override; + } + if (siteData.title) { + link.label = siteData.title; + } + if (siteData.search_shortcut) { + link = await this.topSiteToSearchTopSite(link); + } else if (siteData.sponsored_position) { + if (contileEnabled && hasContileTiles) { + continue; + } + const { + sponsored_position, + sponsored_tile_id, + sponsored_impression_url, + sponsored_click_url, + } = siteData; + link = { + sponsored_position, + sponsored_tile_id, + sponsored_impression_url, + sponsored_click_url, + show_sponsored_label: link.hostname !== "yandex", + ...link, + }; + } + DEFAULT_TOP_SITES.push(link); + } + + this.refresh({ broadcast: true, isStartup }); + } + + refreshDefaults(sites, { isStartup = false } = {}) { + // Clear out the array of any previous defaults + DEFAULT_TOP_SITES.length = 0; + + // Add default sites if any based on the pref + if (sites) { + for (const url of sites.split(",")) { + const site = { + isDefault: true, + url, + }; + site.hostname = shortURL(site); + DEFAULT_TOP_SITES.push(site); + } + } + + this.refresh({ broadcast: true, isStartup }); + } + + async _getRemoteConfig(firstTime = true) { + if (!this._remoteConfig) { + this._remoteConfig = await lazy.RemoteSettings("top-sites"); + this._remoteConfig.on("sync", () => { + this._readDefaults(); + }); + } + + let result = []; + let failed = false; + try { + result = await this._remoteConfig.get(); + } catch (ex) { + console.error(ex); + failed = true; + } + if (!result.length) { + console.error("Received empty top sites configuration!"); + failed = true; + } + // If we failed, or the result is empty, try loading from the local dump. + if (firstTime && failed) { + await this._remoteConfig.db.clear(); + // Now call this again. + return this._getRemoteConfig(false); + } + + // Sort sites based on the "order" attribute. + result.sort((a, b) => a.order - b.order); + + result = result.filter(topsite => { + // Filter by region. + if (topsite.exclude_regions?.includes(lazy.Region.home)) { + return false; + } + if ( + topsite.include_regions?.length && + !topsite.include_regions.includes(lazy.Region.home) + ) { + return false; + } + + // Filter by locale. + if (topsite.exclude_locales?.includes(Services.locale.appLocaleAsBCP47)) { + return false; + } + if ( + topsite.include_locales?.length && + !topsite.include_locales.includes(Services.locale.appLocaleAsBCP47) + ) { + return false; + } + + // Filter by experiment. + // Exclude this top site if any of the specified experiments are running. + if ( + topsite.exclude_experiments?.some(experimentID => + Services.prefs.getBoolPref( + DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID, + false + ) + ) + ) { + return false; + } + // Exclude this top site if none of the specified experiments are running. + if ( + topsite.include_experiments?.length && + topsite.include_experiments.every( + experimentID => + !Services.prefs.getBoolPref( + DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID, + false + ) + ) + ) { + return false; + } + + return true; + }); + + return result; + } + + filterForThumbnailExpiration(callback) { + const { rows } = this.store.getState().TopSites; + callback( + rows.reduce((acc, site) => { + acc.push(site.url); + if (site.customScreenshotURL) { + acc.push(site.customScreenshotURL); + } + return acc; + }, []) + ); + } + + /** + * shouldFilterSearchTile - is default filtering enabled and does a given hostname match the user's default search engine? + * + * @param {string} hostname a top site hostname, such as "amazon" or "foo" + * @returns {bool} + */ + shouldFilterSearchTile(hostname) { + if ( + this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] && + (SEARCH_FILTERS.includes(hostname) || + hostname === this._currentSearchHostname) + ) { + return true; + } + return false; + } + + /** + * _maybeInsertSearchShortcuts - if the search shortcuts experiment is running, + * insert search shortcuts if needed + * + * @param {Array} plainPinnedSites (from the pinnedSitesCache) + * @returns {boolean} Did we insert any search shortcuts? + */ + async _maybeInsertSearchShortcuts(plainPinnedSites) { + // Only insert shortcuts if the experiment is running + if (this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) { + // We don't want to insert shortcuts we've previously inserted + const prevInsertedShortcuts = this.store + .getState() + .Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(",") + .filter(s => s); // Filter out empty strings + const newInsertedShortcuts = []; + + let shouldPin = this._useRemoteSetting + ? DEFAULT_TOP_SITES.filter(s => s.searchTopSite).map(s => s.hostname) + : this.store + .getState() + .Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF].split(","); + shouldPin = shouldPin + .map(getSearchProvider) + .filter(s => s && s.shortURL !== this._currentSearchHostname); + + // If we've previously inserted all search shortcuts return early + if ( + shouldPin.every(shortcut => + prevInsertedShortcuts.includes(shortcut.shortURL) + ) + ) { + return false; + } + + const numberOfSlots = + this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW; + + // The plainPinnedSites array is populated with pinned sites at their + // respective indices, and null everywhere else, but is not always the + // right length + const emptySlots = Math.max(numberOfSlots - plainPinnedSites.length, 0); + const pinnedSites = [...plainPinnedSites].concat( + Array(emptySlots).fill(null) + ); + + const tryToInsertSearchShortcut = async shortcut => { + const nextAvailable = pinnedSites.indexOf(null); + // Only add a search shortcut if the site isn't already pinned, we + // haven't previously inserted it, there's space to pin it, and the + // search engine is available in Firefox + if ( + !pinnedSites.find(s => s && shortURL(s) === shortcut.shortURL) && + !prevInsertedShortcuts.includes(shortcut.shortURL) && + nextAvailable > -1 && + (await checkHasSearchEngine(shortcut.keyword)) + ) { + const site = await this.topSiteToSearchTopSite({ url: shortcut.url }); + this._pinSiteAt(site, nextAvailable); + pinnedSites[nextAvailable] = site; + newInsertedShortcuts.push(shortcut.shortURL); + } + }; + + for (let shortcut of shouldPin) { + await tryToInsertSearchShortcut(shortcut); + } + + if (newInsertedShortcuts.length) { + this.store.dispatch( + ac.SetPref( + SEARCH_SHORTCUTS_HAVE_PINNED_PREF, + prevInsertedShortcuts.concat(newInsertedShortcuts).join(",") + ) + ); + return true; + } + } + + return false; + } + + /** + * 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); + } + + /** + * Fetch topsites spocs from the DiscoveryStream feed. + * + * @returns {Array} An array of sponsored tile objects. + */ + fetchDiscoveryStreamSpocs() { + let sponsored = []; + const { DiscoveryStream } = this.store.getState(); + if (DiscoveryStream) { + const discoveryStreamSpocs = + DiscoveryStream.spocs.data["sponsored-topsites"]?.items || []; + // Find the first component of a type and remove it from layout + const findSponsoredTopsitesPositions = name => { + for (const row of DiscoveryStream.layout) { + for (const component of row.components) { + if (component.placement?.name === name) { + return component.spocs.positions; + } + } + } + return null; + }; + + // Get positions from layout for now. This could be improved if we store position data in state. + const discoveryStreamSpocPositions = + findSponsoredTopsitesPositions("sponsored-topsites"); + + if (discoveryStreamSpocPositions?.length) { + function reformatImageURL(url, width, height) { + // Change the image URL to request a size tailored for the parent container width + // Also: force JPEG, quality 60, no upscaling, no EXIF data + // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html + // For now we wrap this in single quotes because this is being used in a url() css rule, and otherwise would cause a parsing error. + return `'https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( + url + )}'`; + } + + // We need to loop through potential spocs and set their positions. + // If we run out of spocs or positions, we stop. + // First, we need to know which array is shortest. This is our exit condition. + const minLength = Math.min( + discoveryStreamSpocPositions.length, + discoveryStreamSpocs.length + ); + // Loop until we run out of spocs or positions. + for (let i = 0; i < minLength; i++) { + const positionIndex = discoveryStreamSpocPositions[i].index; + const spoc = discoveryStreamSpocs[i]; + const link = { + favicon: reformatImageURL(spoc.raw_image_src, 96, 96), + faviconSize: 96, + type: "SPOC", + label: spoc.title || spoc.sponsor, + title: spoc.title || spoc.sponsor, + url: spoc.url, + flightId: spoc.flight_id, + id: spoc.id, + guid: spoc.id, + shim: spoc.shim, + // For now we are assuming position based on intended position. + // Actual position can shift based on other content. + // We send the intended position in the ping. + pos: positionIndex, + // Set this so that SPOC topsites won't be shown in the URL bar. + // See Bug 1822027. Note that `sponsored_position` is 1-based. + sponsored_position: positionIndex + 1, + // This is used for topsites deduping. + hostname: shortURL({ url: spoc.url }), + partner: SPONSORED_TILE_PARTNER_MOZ_SALES, + }; + sponsored.push(link); + } + } + } + return sponsored; + } + + // eslint-disable-next-line max-statements + async getLinksWithDefaults(isStartup = false) { + const prefValues = this.store.getState().Prefs.values; + const numItems = prefValues[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW; + const searchShortcutsExperiment = prefValues[SEARCH_SHORTCUTS_EXPERIMENT]; + // We must wait for search services to initialize in order to access default + // search engine properties without triggering a synchronous initialization + try { + await Services.search.init(); + } catch { + // We continue anyway because we want the user to see their sponsored, + // saved, or visited shortcut tiles even if search engines are not + // available. + } + + // Get all frecent sites from history. + let frecent = []; + let cache; + try { + // Request can throw if executing the linkGetter inside LinksCache returns + // a null object. + cache = await this.frecentCache.request({ + // We need to overquery due to the top 5 alexa search + default search possibly being removed + numItems: numItems + SEARCH_FILTERS.length + 1, + topsiteFrecency: FRECENCY_THRESHOLD, + }); + } catch (ex) { + cache = []; + } + + for (let link of cache) { + // The cache can contain null values. + if (!link) { + continue; + } + const hostname = shortURL(link); + if (!this.shouldFilterSearchTile(hostname)) { + frecent.push({ + ...(searchShortcutsExperiment + ? await this.topSiteToSearchTopSite(link) + : link), + hostname, + }); + } + } + + // Get defaults. + let contileSponsored = []; + let notBlockedDefaultSites = []; + for (let link of DEFAULT_TOP_SITES) { + // For sponsored Yandex links, default filtering is reversed: we only + // show them if Yandex is the default search engine. + if (link.sponsored_position && link.hostname === "yandex") { + if (link.hostname !== this._currentSearchHostname) { + continue; + } + } else if (this.shouldFilterSearchTile(link.hostname)) { + continue; + } + // Drop blocked default sites. + if ( + lazy.NewTabUtils.blockedLinks.isBlocked({ + url: link.url, + }) + ) { + continue; + } + // If we've previously blocked a search shortcut, remove the default top site + // that matches the hostname + const searchProvider = getSearchProvider(shortURL(link)); + if ( + searchProvider && + lazy.NewTabUtils.blockedLinks.isBlocked({ url: searchProvider.url }) + ) { + continue; + } + if (link.sponsored_position) { + if (!prefValues[SHOW_SPONSORED_PREF]) { + continue; + } + contileSponsored[link.sponsored_position - 1] = link; + + // Unpin search shortcut if present for the sponsored link to be shown + // instead. + this._unpinSearchShortcut(link.hostname); + } else { + notBlockedDefaultSites.push( + searchShortcutsExperiment + ? await this.topSiteToSearchTopSite(link) + : link + ); + } + } + this._telemetryUtility.determineFilteredTilesAndSetToDismissed( + contileSponsored + ); + + const discoverySponsored = this.fetchDiscoveryStreamSpocs(); + this._telemetryUtility.setTiles(discoverySponsored); + + const sponsored = this._mergeSponsoredLinks({ + [SPONSORED_TILE_PARTNER_AMP]: contileSponsored, + [SPONSORED_TILE_PARTNER_MOZ_SALES]: discoverySponsored, + }); + + this._maybeCapSponsoredLinks(sponsored); + + // This will set all extra tiles to oversold, including moz-sales. + this._telemetryUtility.determineFilteredTilesAndSetToOversold(sponsored); + + // Get pinned links augmented with desired properties + let plainPinned = await this.pinnedCache.request(); + + // Insert search shortcuts if we need to. + // _maybeInsertSearchShortcuts returns true if any search shortcuts are + // inserted, meaning we need to expire and refresh the pinnedCache + if (await this._maybeInsertSearchShortcuts(plainPinned)) { + this.pinnedCache.expire(); + plainPinned = await this.pinnedCache.request(); + } + + const pinned = await Promise.all( + plainPinned.map(async link => { + if (!link) { + return link; + } + + // Drop pinned search shortcuts when their engine has been removed / hidden. + if (link.searchTopSite) { + const searchProvider = getSearchProvider(shortURL(link)); + if ( + !searchProvider || + !(await checkHasSearchEngine(searchProvider.keyword)) + ) { + return null; + } + } + + // Copy all properties from a frecent link and add more + const finder = other => other.url === link.url; + + // Remove frecent link's screenshot if pinned link has a custom one + const frecentSite = frecent.find(finder); + if (frecentSite && link.customScreenshotURL) { + delete frecentSite.screenshot; + } + // If the link is a frecent site, do not copy over 'isDefault', else check + // if the site is a default site + const copy = Object.assign( + {}, + frecentSite || { isDefault: !!notBlockedDefaultSites.find(finder) }, + link, + { hostname: shortURL(link) }, + { searchTopSite: !!link.searchTopSite } + ); + + // Add in favicons if we don't already have it + if (!copy.favicon) { + try { + lazy.NewTabUtils.activityStreamProvider._faviconBytesToDataURI( + await lazy.NewTabUtils.activityStreamProvider._addFavicons([copy]) + ); + + for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) { + copy.__sharedCache.updateLink(prop, copy[prop]); + } + } catch (e) { + // Some issue with favicon, so just continue without one + } + } + + return copy; + }) + ); + + // Remove any duplicates from frecent and default sites + const [, dedupedSponsored, dedupedFrecent, dedupedDefaults] = + this.dedupe.group(pinned, sponsored, frecent, notBlockedDefaultSites); + const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults]; + + // Remove adult sites if we need to + const checkedAdult = lazy.FilterAdult.filter(dedupedUnpinned); + + // Insert the original pinned sites into the deduped frecent and defaults. + let withPinned = insertPinned(checkedAdult, pinned); + // Insert sponsored sites at their desired position. + dedupedSponsored.forEach(link => { + if (!link) { + return; + } + let index = link.sponsored_position - 1; + if (index >= withPinned.length) { + withPinned[index] = link; + } else if (withPinned[index]?.sponsored_position) { + // We currently want DiscoveryStream spocs to replace existing spocs. + withPinned[index] = link; + } else { + withPinned.splice(index, 0, link); + } + }); + // Remove excess items after we inserted sponsored ones. + withPinned = withPinned.slice(0, numItems); + + // Now, get a tippy top icon, a rich icon, or screenshot for every item + for (const link of withPinned) { + if (link) { + // If there is a custom screenshot this is the only image we display + if (link.customScreenshotURL) { + this._fetchScreenshot(link, link.customScreenshotURL, isStartup); + } else if (link.searchTopSite && !link.isDefault) { + await this._attachTippyTopIconForSearchShortcut(link, link.label); + } else { + this._fetchIcon(link, isStartup); + } + + // Remove internal properties that might be updated after dispatch + delete link.__sharedCache; + + // Indicate that these links should get a frecency bonus when clicked + link.typedBonus = true; + } + } + + this._linksWithDefaults = withPinned; + + this._telemetryUtility.finalizeNewtabPingFields(dedupedSponsored); + return withPinned; + } + + /** + * Cap sponsored links if they're more than the specified maximum. + * + * @param {Array} links An array of sponsored links. Capping will be performed in-place. + */ + _maybeCapSponsoredLinks(links) { + // Set maximum sponsored top sites + const maxSponsored = + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_MAX_SPONSORED + ) ?? MAX_NUM_SPONSORED; + if (links.length > maxSponsored) { + links.length = maxSponsored; + } + } + + /** + * Merge sponsored links from all the partners using SOV if present. + * For each tile position, the user is assigned to one partner via stable sampling. + * If the chosen partner doesn't have a tile to serve, another tile from a different + * partner is used as the replacement. + * + * @param {object} sponsoredLinks An object with sponsored links from all the partners. + * @returns {Array} An array of merged sponsored links. + */ + _mergeSponsoredLinks(sponsoredLinks) { + const { positions: allocatedPositions, ready: sovReady } = + this.store.getState().TopSites.sov || {}; + if ( + !this._contile.sov || + !sovReady || + !lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_SOV_ENABLED + ) + ) { + return Object.values(sponsoredLinks).flat(); + } + + // AMP links might have empty slots, remove them as SOV doesn't need those. + sponsoredLinks[SPONSORED_TILE_PARTNER_AMP] = + sponsoredLinks[SPONSORED_TILE_PARTNER_AMP].filter(Boolean); + + let sponsored = []; + let chosenPartners = []; + + for (const allocation of allocatedPositions) { + let link = null; + const { assignedPartner } = allocation; + if (assignedPartner) { + // Unknown partners are allowed so that new parters can be added to Shepherd + // sooner without waiting for client changes. + link = sponsoredLinks[assignedPartner]?.shift(); + } + + if (!link) { + // If the chosen partner doesn't have a tile for this postion, choose any + // one from another group. For simplicity, we do _not_ do resampling here + // against the remaining partners. + for (const partner of SPONSORED_TILE_PARTNERS) { + if ( + partner === assignedPartner || + sponsoredLinks[partner].length === 0 + ) { + continue; + } + link = sponsoredLinks[partner].shift(); + break; + } + + if (!link) { + // No more links to be added across all the partners, just return. + if (chosenPartners.length) { + Glean.newtab.sovAllocation.set( + chosenPartners.map(entry => JSON.stringify(entry)) + ); + } + return sponsored; + } + } + + // Update the position fields. Note that postion is also 1-based in SOV. + link.sponsored_position = allocation.position; + if (link.pos !== undefined) { + // Pocket `pos` is 0-based. + link.pos = allocation.position - 1; + } + sponsored.push(link); + + chosenPartners.push({ + pos: allocation.position, + assigned: assignedPartner, // The assigned partner based on SOV + chosen: link.partner, + }); + } + // Record chosen partners to glean + if (chosenPartners.length) { + Glean.newtab.sovAllocation.set( + chosenPartners.map(entry => JSON.stringify(entry)) + ); + } + + // add the remaining contile sponsoredLinks when nimbus variable present + if ( + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED + ) + ) { + return sponsored.concat(sponsoredLinks[SPONSORED_TILE_PARTNER_AMP]); + } + + return sponsored; + } + + /** + * Attach TippyTop icon to the given search shortcut + * + * Note that it queries the search form URL from search service For Yandex, + * and uses it to choose the best icon for its shortcut variants. + * + * @param {object} link A link object with a `url` property + * @param {string} keyword Search keyword + */ + async _attachTippyTopIconForSearchShortcut(link, keyword) { + if ( + ["@\u044F\u043D\u0434\u0435\u043A\u0441", "@yandex"].includes(keyword) + ) { + let site = { url: link.url }; + site.url = (await getSearchFormURL(keyword)) || site.url; + this._tippyTopProvider.processSite(site); + link.tippyTopIcon = site.tippyTopIcon; + link.smallFavicon = site.smallFavicon; + link.backgroundColor = site.backgroundColor; + } else { + this._tippyTopProvider.processSite(link); + } + } + + /** + * Refresh the top sites data for content. + * + * @param {object} options + * @param {bool} options.broadcast Should the update be broadcasted. + * @param {bool} options.isStartup Being called while TopSitesFeed is initting. + */ + async refresh(options = {}) { + // Avoiding refreshing if it's already happening. + if (this._refreshing) { + return; + } + if (!this._startedUp && !options.isStartup) { + // Initial refresh still pending. + return; + } + this._refreshing = true; + this._startedUp = true; + + if (!this._tippyTopProvider.initialized) { + await this._tippyTopProvider.init(); + } + + const links = await this.getLinksWithDefaults({ + isStartup: options.isStartup, + }); + const newAction = { type: at.TOP_SITES_UPDATED, data: { links } }; + let storedPrefs; + try { + storedPrefs = (await this._storage.get(SECTION_ID)) || {}; + } catch (e) { + storedPrefs = {}; + console.error("Problem getting stored prefs for TopSites"); + } + newAction.data.pref = getDefaultOptions(storedPrefs); + + if (options.isStartup) { + newAction.meta = { + isStartup: true, + }; + } + + if (options.broadcast) { + // Broadcast an update to all open content pages + this.store.dispatch(ac.BroadcastToContent(newAction)); + } else { + // Don't broadcast only update the state and update the preloaded tab. + this.store.dispatch(ac.AlsoToPreloaded(newAction)); + } + this._refreshing = false; + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "topsites-refreshed"); + } + } + + // Allocate ad positions to partners based on SOV via stable randomization. + async allocatePositions() { + // If the fetch to get sov fails for whatever reason, we can just return here. + // Code that uses this falls back to flattening allocations instead if this has failed. + if (!this._contile.sov) { + return; + } + // This sample input should ensure we return the same result for this allocation, + // even if called from other parts of the code. + const sampleInput = `${lazy.contextId}-${this._contile.sov.name}`; + const allocatedPositions = []; + for (const allocation of this._contile.sov.allocations) { + const allocatedPosition = { + position: allocation.position, + }; + allocatedPositions.push(allocatedPosition); + const ratios = allocation.allocation.map(alloc => alloc.percentage); + if (ratios.length) { + const index = await lazy.Sampling.ratioSample(sampleInput, ratios); + allocatedPosition.assignedPartner = + allocation.allocation[index].partner; + } + } + + this.store.dispatch( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: !!allocatedPositions.length, + positions: allocatedPositions, + }, + }) + ); + } + + async updateCustomSearchShortcuts(isStartup = false) { + if (!this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) { + return; + } + + if (!this._tippyTopProvider.initialized) { + await this._tippyTopProvider.init(); + } + + // Populate the state with available search shortcuts + let searchShortcuts = []; + for (const engine of await Services.search.getAppProvidedEngines()) { + const shortcut = CUSTOM_SEARCH_SHORTCUTS.find(s => + engine.aliases.includes(s.keyword) + ); + if (shortcut) { + let clone = { ...shortcut }; + await this._attachTippyTopIconForSearchShortcut(clone, clone.keyword); + searchShortcuts.push(clone); + } + } + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.UPDATE_SEARCH_SHORTCUTS, + data: { searchShortcuts }, + meta: { + isStartup, + }, + }) + ); + } + + async topSiteToSearchTopSite(site) { + const searchProvider = getSearchProvider(shortURL(site)); + if ( + !searchProvider || + !(await checkHasSearchEngine(searchProvider.keyword)) + ) { + return site; + } + return { + ...site, + searchTopSite: true, + label: searchProvider.keyword, + }; + } + + /** + * Get an image for the link preferring tippy top, rich favicon, screenshots. + */ + async _fetchIcon(link, isStartup = false) { + // Nothing to do if we already have a rich icon from the page + if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) { + return; + } + + // Nothing more to do if we can use a default tippy top icon + this._tippyTopProvider.processSite(link); + if (link.tippyTopIcon) { + return; + } + + // Make a request for a better icon + this._requestRichIcon(link.url); + + // Also request a screenshot if we don't have one yet + await this._fetchScreenshot(link, link.url, isStartup); + } + + /** + * Fetch, cache and broadcast a screenshot for a specific topsite. + * + * @param {object} link cached topsite object + * @param {string} url where to fetch the image from + * @param {boolean} isStartup Whether the screenshot is fetched while + * initting TopSitesFeed. + */ + async _fetchScreenshot(link, url, isStartup = false) { + // We shouldn't bother caching screenshots if they won't be shown. + if ( + link.screenshot || + !this.store.getState().Prefs.values[SHOWN_ON_NEWTAB_PREF] + ) { + return; + } + await lazy.Screenshots.maybeCacheScreenshot( + link, + url, + "screenshot", + screenshot => + this.store.dispatch( + ac.BroadcastToContent({ + data: { screenshot, url: link.url }, + type: at.SCREENSHOT_UPDATED, + meta: { + isStartup, + }, + }) + ) + ); + } + + /** + * Dispatch screenshot preview to target or notify if request failed. + * + * @param {string} url The URL used to capture the screenshot + * @param {string} target Id of content process where to dispatch the result + */ + async getScreenshotPreview(url, target) { + const preview = (await lazy.Screenshots.getScreenshotForURL(url)) || ""; + this.store.dispatch( + ac.OnlyToOneContent( + { + data: { url, preview }, + type: at.PREVIEW_RESPONSE, + }, + target + ) + ); + } + + _requestRichIcon(url) { + this.store.dispatch({ + type: at.RICH_ICON_MISSING, + data: { url }, + }); + } + + updateSectionPrefs(collapsed) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.TOP_SITES_PREFS_UPDATED, + data: { pref: collapsed }, + }) + ); + } + + /** + * Inform others that top sites data has been updated due to pinned changes. + */ + _broadcastPinnedSitesUpdated() { + // Pinned data changed, so make sure we get latest + this.pinnedCache.expire(); + + // Refresh to update pinned sites with screenshots, trigger deduping, etc. + this.refresh({ broadcast: true }); + } + + /** + * Pin a site at a specific position saving only the desired keys. + * + * @param customScreenshotURL {string} User set URL of preview image for site + * @param label {string} User set string of custom site name + */ + // To refactor in Bug 1891997 + /* eslint-enable jsdoc/check-param-names */ + async _pinSiteAt({ customScreenshotURL, label, url, searchTopSite }, index) { + const toPin = { url }; + if (label) { + toPin.label = label; + } + if (customScreenshotURL) { + toPin.customScreenshotURL = customScreenshotURL; + } + if (searchTopSite) { + toPin.searchTopSite = searchTopSite; + } + lazy.NewTabUtils.pinnedLinks.pin(toPin, index); + + await this._clearLinkCustomScreenshot({ customScreenshotURL, url }); + } + + async _clearLinkCustomScreenshot(site) { + // If screenshot url changed or was removed we need to update the cached link obj + if (site.customScreenshotURL !== undefined) { + const pinned = await this.pinnedCache.request(); + const link = pinned.find(pin => pin && pin.url === site.url); + if (link && link.customScreenshotURL !== site.customScreenshotURL) { + link.__sharedCache.updateLink("screenshot", undefined); + } + } + } + + /** + * Handle a pin action of a site to a position. + */ + async pin(action) { + let { site, index } = action.data; + index = this._adjustPinIndexForSponsoredLinks(site, index); + // If valid index provided, pin at that position + if (index >= 0) { + await this._pinSiteAt(site, index); + this._broadcastPinnedSitesUpdated(); + } else { + // Bug 1458658. If the top site is being pinned from an 'Add a Top Site' option, + // then we want to make sure to unblock that link if it has previously been + // blocked. We know if the site has been added because the index will be -1. + if (index === -1) { + lazy.NewTabUtils.blockedLinks.unblock({ url: site.url }); + this.frecentCache.expire(); + } + this.insert(action); + } + } + + /** + * Handle an unpin action of a site. + */ + unpin(action) { + const { site } = action.data; + lazy.NewTabUtils.pinnedLinks.unpin(site); + this._broadcastPinnedSitesUpdated(); + } + + unpinAllSearchShortcuts() { + Services.prefs.clearUserPref( + `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}` + ); + for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) { + if (pinnedLink && pinnedLink.searchTopSite) { + lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink); + } + } + this.pinnedCache.expire(); + } + + _unpinSearchShortcut(vendor) { + for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) { + if ( + pinnedLink && + pinnedLink.searchTopSite && + shortURL(pinnedLink) === vendor + ) { + lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink); + this.pinnedCache.expire(); + + const prevInsertedShortcuts = this.store + .getState() + .Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(","); + this.store.dispatch( + ac.SetPref( + SEARCH_SHORTCUTS_HAVE_PINNED_PREF, + prevInsertedShortcuts.filter(s => s !== vendor).join(",") + ) + ); + break; + } + } + } + + /** + * Reduces the given pinning index by the number of preceding sponsored + * sites, to accomodate for sponsored sites pushing pinned ones to the side, + * effectively increasing their index again. + */ + _adjustPinIndexForSponsoredLinks(site, index) { + if (!this._linksWithDefaults) { + return index; + } + // Adjust insertion index for sponsored sites since their position is + // fixed. + let adjustedIndex = index; + for (let i = 0; i < index; i++) { + const link = this._linksWithDefaults[i]; + if ( + link && + link.sponsored_position && + this._linksWithDefaults[i]?.url !== site.url + ) { + adjustedIndex--; + } + } + return adjustedIndex; + } + + /** + * Insert a site to pin at a position shifting over any other pinned sites. + */ + _insertPin(site, originalIndex, draggedFromIndex) { + let index = this._adjustPinIndexForSponsoredLinks(site, originalIndex); + + // Don't insert any pins past the end of the visible top sites. Otherwise, + // we can end up with a bunch of pinned sites that can never be unpinned again + // from the UI. + const topSitesCount = + this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW; + if (index >= topSitesCount) { + return; + } + + let pinned = lazy.NewTabUtils.pinnedLinks.links; + if (!pinned[index]) { + this._pinSiteAt(site, index); + } else { + pinned[draggedFromIndex] = null; + // Find the hole to shift the pinned site(s) towards. We shift towards the + // hole left by the site being dragged. + let holeIndex = index; + const indexStep = index > draggedFromIndex ? -1 : 1; + while (pinned[holeIndex]) { + holeIndex += indexStep; + } + if (holeIndex >= topSitesCount || holeIndex < 0) { + // There are no holes, so we will effectively unpin the last slot and shifting + // towards it. This only happens when adding a new top site to an already + // fully pinned grid. + holeIndex = topSitesCount - 1; + } + + // Shift towards the hole. + const shiftingStep = holeIndex > index ? -1 : 1; + while (holeIndex !== index) { + const nextIndex = holeIndex + shiftingStep; + this._pinSiteAt(pinned[nextIndex], holeIndex); + holeIndex = nextIndex; + } + this._pinSiteAt(site, index); + } + } + + /** + * Handle an insert (drop/add) action of a site. + */ + async insert(action) { + let { index } = action.data; + // Treat invalid pin index values (e.g., -1, undefined) as insert in the first position + if (!(index > 0)) { + index = 0; + } + + // Inserting a top site pins it in the specified slot, pushing over any link already + // pinned in the slot (unless it's the last slot, then it replaces). + this._insertPin( + action.data.site, + index, + action.data.draggedFromIndex !== undefined + ? action.data.draggedFromIndex + : this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW + ); + + await this._clearLinkCustomScreenshot(action.data.site); + this._broadcastPinnedSitesUpdated(); + } + + updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }) { + // Unpin the deletedShortcuts. + deletedShortcuts.forEach(({ url }) => { + lazy.NewTabUtils.pinnedLinks.unpin({ url }); + }); + + // Pin the addedShortcuts. + const numberOfSlots = + this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW; + addedShortcuts.forEach(shortcut => { + // Find first hole in pinnedLinks. + let index = lazy.NewTabUtils.pinnedLinks.links.findIndex(link => !link); + if ( + index < 0 && + lazy.NewTabUtils.pinnedLinks.links.length + 1 < numberOfSlots + ) { + // pinnedLinks can have less slots than the total available. + index = lazy.NewTabUtils.pinnedLinks.links.length; + } + if (index >= 0) { + lazy.NewTabUtils.pinnedLinks.pin(shortcut, index); + } else { + // No slots available, we need to do an insert in first slot and push over other pinned links. + this._insertPin(shortcut, 0, numberOfSlots); + } + }); + + this._broadcastPinnedSitesUpdated(); + } + + onAction(action) { + switch (action.type) { + case at.INIT: + this.init(); + this.updateCustomSearchShortcuts(true /* isStartup */); + break; + case at.SYSTEM_TICK: + this.refresh({ broadcast: false }); + this._contile.periodicUpdate(); + break; + // All these actions mean we need new top sites + case at.PLACES_HISTORY_CLEARED: + case at.PLACES_LINKS_DELETED: + this.frecentCache.expire(); + this.refresh({ broadcast: true }); + break; + case at.PLACES_LINKS_CHANGED: + this.frecentCache.expire(); + this.refresh({ broadcast: false }); + break; + case at.PLACES_LINK_BLOCKED: + this.frecentCache.expire(); + this.pinnedCache.expire(); + this.refresh({ broadcast: true }); + break; + case at.PREF_CHANGED: + switch (action.data.name) { + case DEFAULT_SITES_PREF: + if (!this._useRemoteSetting) { + this.refreshDefaults(action.data.value); + } + break; + case ROWS_PREF: + case FILTER_DEFAULT_SEARCH_PREF: + case SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF: + this.refresh({ broadcast: true }); + break; + case SHOW_SPONSORED_PREF: + if ( + lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_CONTILE_ENABLED + ) + ) { + this._contile.refresh(); + } else { + this.refresh({ broadcast: true }); + } + if (!action.data.value) { + this._contile._resetContileCachePrefs(); + } + + break; + case SEARCH_SHORTCUTS_EXPERIMENT: + if (action.data.value) { + this.updateCustomSearchShortcuts(); + } else { + this.unpinAllSearchShortcuts(); + } + this.refresh({ broadcast: true }); + } + break; + case at.UPDATE_SECTION_PREFS: + if (action.data.id === SECTION_ID) { + this.updateSectionPrefs(action.data.value); + } + break; + case at.PREFS_INITIAL_VALUES: + if (!this._useRemoteSetting) { + this.refreshDefaults(action.data[DEFAULT_SITES_PREF]); + } + break; + case at.TOP_SITES_PIN: + this.pin(action); + break; + case at.TOP_SITES_UNPIN: + this.unpin(action); + break; + case at.TOP_SITES_INSERT: + this.insert(action); + break; + case at.PREVIEW_REQUEST: + this.getScreenshotPreview(action.data.url, action.meta.fromTarget); + break; + case at.UPDATE_PINNED_SEARCH_SHORTCUTS: + this.updatePinnedSearchShortcuts(action.data); + break; + case at.DISCOVERY_STREAM_SPOCS_UPDATE: + // Refresh to update sponsored topsites. + this.refresh({ broadcast: true, isStartup: action.meta.isStartup }); + break; + case at.UNINIT: + this.uninit(); + break; + } + } +} + +export const TopSites = new _TopSites(); diff --git a/browser/components/topsites/moz.build b/browser/components/topsites/moz.build new file mode 100644 index 0000000000..f8c7a96fa2 --- /dev/null +++ b/browser/components/topsites/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES += ["TopSites.sys.mjs"] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Top Sites") diff --git a/browser/components/topsites/test/unit/test_top_sites.js b/browser/components/topsites/test/unit/test_top_sites.js new file mode 100644 index 0000000000..3de5f43262 --- /dev/null +++ b/browser/components/topsites/test/unit/test_top_sites.js @@ -0,0 +1,3571 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TopSites, DEFAULT_TOP_SITES } = ChromeUtils.importESModule( + "resource:///modules/TopSites.sys.mjs" +); + +const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + shortURL: "resource://activity-stream/lib/ShortURL.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", + SearchService: "resource://gre/modules/SearchService.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + TOP_SITES_DEFAULT_ROWS: "resource://activity-stream/common/Reducers.sys.mjs", + TOP_SITES_MAX_SITES_PER_ROW: + "resource://activity-stream/common/Reducers.sys.mjs", +}); + +const FAKE_FAVICON = "data987"; +const FAKE_FAVICON_SIZE = 128; +const FAKE_FRECENCY = 200; +const FAKE_LINKS = new Array(2 * TOP_SITES_MAX_SITES_PER_ROW) + .fill(null) + .map((v, i) => ({ + frecency: FAKE_FRECENCY, + url: `http://www.site${i}.com`, + })); +const FAKE_SCREENSHOT = "data123"; +const SEARCH_SHORTCUTS_EXPERIMENT_PREF = "improvesearch.topSiteSearchShortcuts"; +const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF = + "improvesearch.topSiteSearchShortcuts.searchEngines"; +const SEARCH_SHORTCUTS_HAVE_PINNED_PREF = + "improvesearch.topSiteSearchShortcuts.havePinned"; +const SHOWN_ON_NEWTAB_PREF = "feeds.topsites"; +const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles"; + +// This pref controls how long the contile cache is valid for in seconds. +const CONTILE_CACHE_VALID_FOR_SECONDS_PREF = + "browser.topsites.contile.cacheValidFor"; +// This pref records when the last contile fetch occurred, as a UNIX timestamp +// in seconds. +const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch"; + +function FakeTippyTopProvider() {} +FakeTippyTopProvider.prototype = { + async init() { + this.initialized = true; + }, + processSite(site) { + return site; + }, +}; + +let gSearchServiceInitStub; +let gGetTopSitesStub; + +function stubTopSites(sandbox) { + let cachedStorage = TopSites._storage; + let cachedStore = TopSites.store; + + async function cleanup() { + if (TopSites._refreshing) { + info("Wait for refresh to finish."); + // Wait for refresh to finish or else removing the store while a process + // is running will result in errors. + await TestUtils.topicObserved("topsites-refreshed"); + } + TopSites._tippyTopProvider.initialized = false; + TopSites._storage = cachedStorage; + TopSites.store = cachedStore; + TopSites.pinnedCache.clear(); + TopSites.frecentCache.clear(); + info("Finished cleaning up TopSites."); + } + + const storage = { + init: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + + // Setup for tests that don't call `init` but require feed.storage + TopSites._storage = storage; + TopSites.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { values: { topSitesRows: 2 } }, + TopSites: { rows: Array(12).fill("site") }, + }, + dbStorage: { getDbTable: sandbox.stub().returns(storage) }, + }; + info("Created mock store for TopSites."); + return cleanup; +} + +add_setup(async () => { + let sandbox = sinon.createSandbox(); + sandbox.stub(SearchService.prototype, "defaultEngine").get(() => { + return { identifier: "ddg", searchForm: "https://duckduckgo.com" }; + }); + + gGetTopSitesStub = sandbox + .stub(NewTabUtils.activityStreamLinks, "getTopSites") + .resolves(FAKE_LINKS); + + gSearchServiceInitStub = sandbox + .stub(SearchService.prototype, "init") + .resolves(); + + sandbox.stub(NewTabUtils.activityStreamProvider, "_faviconBytesToDataURI"); + + sandbox + .stub(NewTabUtils.activityStreamProvider, "_addFavicons") + .callsFake(l => { + return Promise.resolve( + l.map(link => { + link.favicon = FAKE_FAVICON; + link.faviconSize = FAKE_FAVICON_SIZE; + return link; + }) + ); + }); + + sandbox.stub(Screenshots, "getScreenshotForURL").resolves(FAKE_SCREENSHOT); + sandbox.spy(Screenshots, "maybeCacheScreenshot"); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test_construction() { + Assert.ok(TopSites._currentSearchHostname, "_currentSearchHostname defined"); +}); + +add_task(async function test_refreshDefaults() { + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + Assert.ok( + !DEFAULT_TOP_SITES.length, + "Should have 0 DEFAULT_TOP_SITES initially." + ); + + info("refreshDefaults should add defaults on PREFS_INITIAL_VALUES"); + TopSites.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "https://foo.com" }, + }); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have 1 DEFAULT_TOP_SITES now." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should add defaults on default.sites PREF_CHANGED"); + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: "default.sites", value: "https://foo.com" }, + }); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have 1 DEFAULT_TOP_SITES now." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should refresh on topSiteRows PREF_CHANGED"); + let refreshStub = sandbox.stub(TopSites, "refresh"); + TopSites.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } }); + Assert.ok(TopSites.refresh.calledOnce, "refresh called"); + refreshStub.restore(); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should have default sites with .isDefault = true"); + TopSites.refreshDefaults("https://foo.com"); + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have a DEFAULT_TOP_SITES now." + ); + Assert.ok( + DEFAULT_TOP_SITES[0].isDefault, + "Lone top site should be the default." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should have default sites with appropriate hostname"); + TopSites.refreshDefaults("https://foo.com"); + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have a DEFAULT_TOP_SITES now." + ); + let [site] = DEFAULT_TOP_SITES; + Assert.equal( + site.hostname, + shortURL(site), + "Lone top site should have the right hostname." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should add no defaults on empty pref"); + TopSites.refreshDefaults(""); + Assert.equal( + DEFAULT_TOP_SITES.length, + 0, + "Should have 0 DEFAULT_TOP_SITES now." + ); + + info("refreshDefaults should be able to clear defaults"); + TopSites.refreshDefaults("https://foo.com"); + TopSites.refreshDefaults(""); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 0, + "Should have 0 DEFAULT_TOP_SITES now." + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_filterForThumbnailExpiration() { + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + + info( + "filterForThumbnailExpiration should pass rows.urls to the callback provided" + ); + const rows = [ + { url: "foo.com" }, + { url: "bar.com", customScreenshotURL: "custom" }, + ]; + TopSites.store.state.TopSites = { rows }; + const stub = sandbox.stub(); + TopSites.filterForThumbnailExpiration(stub); + Assert.ok(stub.calledOnce); + Assert.ok(stub.calledWithExactly(["foo.com", "bar.com", "custom"])); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_on_SearchService_init_failure() { + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + + TopSites.refreshDefaults("https://foo.com"); + + gSearchServiceInitStub.rejects( + new Error("Simulating search init failures") + ); + + const result = await TopSites.getLinksWithDefaults(); + Assert.ok(result); + + gSearchServiceInitStub.resolves(); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults() { + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + + TopSites.refreshDefaults("https://foo.com"); + + info("getLinksWithDefaults should get the links from NewTabUtils"); + let result = await TopSites.getLinksWithDefaults(); + + const reference = FAKE_LINKS.map(site => + Object.assign({}, site, { + hostname: shortURL(site), + typedBonus: true, + }) + ); + + Assert.deepEqual(result, reference); + Assert.ok(NewTabUtils.activityStreamLinks.getTopSites.calledOnce); + + info("getLinksWithDefaults should indicate the links get typed bonus"); + Assert.ok(result[0].typedBonus, "Expected typed bonus property to be true."); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_filterAdult() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should filter out non-pinned adult sites"); + + sandbox.stub(FilterAdult, "filter").returns([]); + const TEST_URL = "https://foo.com/"; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [{ url: TEST_URL }]); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const result = await TopSites.getLinksWithDefaults(); + Assert.ok(FilterAdult.filter.calledOnce); + Assert.equal(result.length, 1); + Assert.equal(result[0].url, TEST_URL); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_caching() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should filter out the defaults that have been blocked" + ); + // make sure we only have one top site, and we block the only default site we have to show + const url = "www.myonlytopsite.com"; + const topsite = { + frecency: FAKE_FRECENCY, + hostname: shortURL({ url }), + typedBonus: true, + url, + }; + + const blockedDefaultSite = { url: "https://foo.com" }; + gGetTopSitesStub.resolves([topsite]); + sandbox.stub(NewTabUtils.blockedLinks, "isBlocked").callsFake(site => { + return site.url === blockedDefaultSite.url; + }); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + const result = await TopSites.getLinksWithDefaults(); + + // what we should be left with is just the top site we added, and not the default site we blocked + Assert.equal(result.length, 1); + Assert.deepEqual(result[0], topsite); + let foundBlocked = result.find(site => site.url === blockedDefaultSite.url); + Assert.ok(!foundBlocked, "Should not have found blocked site."); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_dedupe() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should call dedupe.group on the links"); + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let stub = sandbox.stub(TopSites.dedupe, "group").callsFake((...id) => id); + await TopSites.getLinksWithDefaults(); + + Assert.ok(stub.calledOnce, "dedupe.group was called once"); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test__dedupe_key() { + let sandbox = sinon.createSandbox(); + + info("_dedupeKey should dedupe on hostname instead of url"); + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let site = { url: "foo", hostname: "bar" }; + let result = TopSites._dedupeKey(site); + + Assert.equal(result, site.hostname, "deduped on hostname"); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_adds_defaults() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should add defaults if there are are not enough links" + ); + const TEST_LINKS = [{ frecency: FAKE_FRECENCY, url: "foo.com" }]; + gGetTopSitesStub.resolves(TEST_LINKS); + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let result = await TopSites.getLinksWithDefaults(); + + let reference = [...TEST_LINKS, ...DEFAULT_TOP_SITES].map(s => + Object.assign({}, s, { + hostname: shortURL(s), + typedBonus: true, + }) + ); + + Assert.deepEqual(result, reference); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_adds_defaults_for_visible_slots() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should only add defaults up to the number of visible slots" + ); + const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; + let testLinks = []; + for (let i = 0; i < numVisible - 1; i++) { + testLinks.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` }); + } + gGetTopSitesStub.resolves(testLinks); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let result = await TopSites.getLinksWithDefaults(); + + let reference = [...testLinks, DEFAULT_TOP_SITES[0]].map(s => + Object.assign({}, s, { + hostname: shortURL(s), + typedBonus: true, + }) + ); + + Assert.equal(result.length, numVisible); + Assert.deepEqual(result, reference); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_no_throw_on_no_links() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should not throw if NewTabUtils returns null"); + gGetTopSitesStub.resolves(null); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + await TopSites.getLinksWithDefaults(); + Assert.ok(true, "getLinksWithDefaults did not throw"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_get_more_on_request() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should get more if the user has asked for more"); + let testLinks = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW) + .fill(null) + .map((v, i) => ({ + frecency: FAKE_FRECENCY, + url: `http://www.site${i}.com`, + })); + gGetTopSitesStub.resolves(testLinks); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const TEST_ROWS = 3; + TopSites.store.state.Prefs.values.topSitesRows = TEST_ROWS; + + let result = await TopSites.getLinksWithDefaults(); + Assert.equal(result.length, TEST_ROWS * TOP_SITES_MAX_SITES_PER_ROW); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_reuse_cache() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should reuse the cache on subsequent calls"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + await TopSites.getLinksWithDefaults(); + await TopSites.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_ignore_cache_on_requesting_more() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should ignore the cache when requesting more"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + await TopSites.getLinksWithDefaults(); + TopSites.store.state.Prefs.values.topSitesRows *= 3; + await TopSites.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledTwice, + "getTopSites called twice" + ); + + sandbox.restore(); + await cleanup(); + } +); + +add_task( + async function test_getLinksWithDefaults_migrate_frecent_screenshot_data() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should migrate frecent screenshot data without getting screenshots again" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + TopSites.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + await TopSites.getLinksWithDefaults(); + + let originalCallCount = Screenshots.getScreenshotForURL.callCount; + TopSites.frecentCache.expire(); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledTwice, + "getTopSites called twice" + ); + Assert.equal( + Screenshots.getScreenshotForURL.callCount, + originalCallCount, + "getScreenshotForURL was not called again." + ); + Assert.equal(result[0].screenshot, FAKE_SCREENSHOT); + + sandbox.restore(); + await cleanup(); + } +); + +add_task( + async function test_getLinksWithDefaults_migrate_pinned_favicon_data() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should migrate pinned favicon data without getting favicons again" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + await TopSites.getLinksWithDefaults(); + + let originalCallCount = + NewTabUtils.activityStreamProvider._addFavicons.callCount; + TopSites.pinnedCache.expire(); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal( + NewTabUtils.activityStreamProvider._addFavicons.callCount, + originalCallCount, + "_addFavicons was not called again." + ); + Assert.equal(result[0].favicon, FAKE_FAVICON); + Assert.equal(result[0].faviconSize, FAKE_FAVICON_SIZE); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_no_internal_properties() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not expose internal link properties"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let result = await TopSites.getLinksWithDefaults(); + + let internal = Object.keys(result[0]).filter(key => key.startsWith("__")); + Assert.equal(internal.join(""), ""); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_copy_frecent_screenshot() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should copy the screenshot of the frecent site if " + + "pinned site doesn't have customScreenshotURL" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const TEST_SCREENSHOT = "screenshot"; + + gGetTopSitesStub.resolves([ + { url: "https://foo.com/", screenshot: TEST_SCREENSHOT }, + ]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, TEST_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_no_copy_frecent_screenshot() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should not copy the frecent screenshot if " + + "customScreenshotURL is set" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resolves([ + { url: "https://foo.com/", screenshot: "screenshot" }, + ]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/", customScreenshotURL: "custom" }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, undefined); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_persist_screenshot() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should keep the same screenshot if no frecent site is found" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const CUSTOM_SCREENSHOT = "custom"; + + gGetTopSitesStub.resolves([]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/", screenshot: CUSTOM_SCREENSHOT }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, CUSTOM_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_no_overwrite_pinned_screenshot() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not overwrite pinned site screenshot"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const EXISTING_SCREENSHOT = "some-screenshot"; + + gGetTopSitesStub.resolves([{ url: "https://foo.com/", screenshot: "foo" }]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + { url: "https://foo.com/", screenshot: EXISTING_SCREENSHOT }, + ]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); + } +); + +add_task( + async function test_getLinksWithDefaults_no_searchTopSite_from_frecent() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not set searchTopSite from frecent site"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const EXISTING_SCREENSHOT = "some-screenshot"; + + gGetTopSitesStub.resolves([ + { + url: "https://foo.com/", + searchTopSite: true, + screenshot: EXISTING_SCREENSHOT, + }, + ]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.ok(!result[0].searchTopSite); + // But it should copy over other properties + Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_concurrency_getTopSites() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults concurrent calls should call the backing data once" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + + await Promise.all([ + TopSites.getLinksWithDefaults(), + TopSites.getLinksWithDefaults(), + ]); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_concurrency_getScreenshotForURL() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults concurrent calls should call the backing data once" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + TopSites.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + Screenshots.getScreenshotForURL.resetHistory(); + + await Promise.all([ + TopSites.getLinksWithDefaults(), + TopSites.getLinksWithDefaults(), + ]); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + Assert.equal( + Screenshots.getScreenshotForURL.callCount, + FAKE_LINKS.length, + "getLinksWithDefaults concurrent calls should get screenshots once per link" + ); + await cleanup(); + + cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + + TopSites.refreshDefaults("https://foo.com"); + + sandbox.stub(TopSites, "_requestRichIcon"); + await Promise.all([ + TopSites.getLinksWithDefaults(), + TopSites.getLinksWithDefaults(), + ]); + + Assert.equal( + TopSites.store.dispatch.callCount, + FAKE_LINKS.length, + "getLinksWithDefaults concurrent calls should dispatch once per link screenshot fetched" + ); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_deduping_no_dedupe_pinned() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not dedupe pinned sites"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + { url: "https://developer.mozilla.org/en-US/docs/Web" }, + { url: "https://developer.mozilla.org/en-US/docs/Learn" }, + ]); + + let sites = await TopSites.getLinksWithDefaults(); + Assert.equal(sites.length, 2 * TOP_SITES_MAX_SITES_PER_ROW); + Assert.equal(sites[0].url, NewTabUtils.pinnedLinks.links[0].url); + Assert.equal(sites[1].url, NewTabUtils.pinnedLinks.links[1].url); + Assert.equal(sites[0].hostname, sites[1].hostname); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_prefer_pinned_sites() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should prefer pinned sites over links"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + { url: "https://developer.mozilla.org/en-US/docs/Web" }, + { url: "https://developer.mozilla.org/en-US/docs/Learn" }, + ]); + + const SECOND_TOP_SITE_URL = "https://www.mozilla.org/"; + + gGetTopSitesStub.resolves([ + { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" }, + { frecency: FAKE_FRECENCY, url: SECOND_TOP_SITE_URL }, + ]); + + let sites = await TopSites.getLinksWithDefaults(); + + // Expecting 3 links where there's 2 pinned and 1 www.mozilla.org, so + // the frecent with matching hostname as pinned is removed. + Assert.equal(sites.length, 3); + Assert.equal(sites[0].url, NewTabUtils.pinnedLinks.links[0].url); + Assert.equal(sites[1].url, NewTabUtils.pinnedLinks.links[1].url); + Assert.equal(sites[2].url, SECOND_TOP_SITE_URL); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_title_and_null() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should return sites that have a title"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://github.com/mozilla/activity-stream" }]); + + let sites = await TopSites.getLinksWithDefaults(); + for (let site of sites) { + Assert.ok(site.hostname); + } + + info("getLinksWithDefaults should not throw for null entries"); + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [null]); + await TopSites.getLinksWithDefaults(); + Assert.ok(true, "getLinksWithDefaults didn't throw"); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_calls__fetchIcon() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should return sites that have a title"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox.spy(TopSites, "_fetchIcon"); + let results = await TopSites.getLinksWithDefaults(); + Assert.ok(results.length, "Got back some results"); + Assert.equal(TopSites._fetchIcon.callCount, results.length); + for (let result of results) { + Assert.ok(TopSites._fetchIcon.calledWith(result)); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_calls__fetchScreenshot() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should call _fetchScreenshot when customScreenshotURL is set" + ); + + gGetTopSitesStub.resolves([]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com", customScreenshotURL: "custom" }]); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox.stub(TopSites, "_fetchScreenshot"); + await TopSites.getLinksWithDefaults(); + + Assert.ok(TopSites._fetchScreenshot.calledWith(sinon.match.object, "custom")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_with_DiscoveryStream() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should add a sponsored topsite from discoverystream to all the valid indices" + ); + + let makeStreamData = index => ({ + layout: [ + { + components: [ + { + placement: { + name: "sponsored-topsites", + }, + spocs: { + positions: [{ index }], + }, + }, + ], + }, + ], + spocs: { + data: { + "sponsored-topsites": { + items: [{ title: "test spoc", url: "https://test-spoc.com" }], + }, + }, + }, + }); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + for (let i = 0; i < FAKE_LINKS.length; i++) { + TopSites.store.state.DiscoveryStream = makeStreamData(i); + const result = await TopSites.getLinksWithDefaults(); + const link = result[i]; + + Assert.equal(link.type, "SPOC"); + Assert.equal(link.title, "test spoc"); + Assert.equal(link.sponsored_position, i + 1); + Assert.equal(link.hostname, "test-spoc"); + Assert.equal(link.url, "https://test-spoc.com"); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_init() { + let sandbox = sinon.createSandbox(); + + sandbox.stub(NimbusFeatures.newtab, "onUpdate"); + + let cleanup = stubTopSites(sandbox); + + sandbox.stub(TopSites, "refresh"); + await TopSites.init(); + + info("TopSites.init should call refresh (broadcast: true)"); + Assert.ok(TopSites.refresh.calledOnce, "refresh called once"); + Assert.ok( + TopSites.refresh.calledWithExactly({ + broadcast: true, + isStartup: true, + }) + ); + + info("TopSites.init should initialise the storage"); + Assert.ok( + TopSites.store.dbStorage.getDbTable.calledOnce, + "getDbTable called once" + ); + Assert.ok( + TopSites.store.dbStorage.getDbTable.calledWithExactly("sectionPrefs") + ); + + info("TopSites.init should call onUpdate to set up Nimbus update listener"); + + Assert.ok( + NimbusFeatures.newtab.onUpdate.calledOnce, + "NimbusFeatures.newtab.onUpdate called once" + ); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh() { + let sandbox = sinon.createSandbox(); + + sandbox.stub(NimbusFeatures.newtab, "onUpdate"); + + let cleanup = stubTopSites(sandbox); + + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + info("TopSites.refresh should wait for tippytop to initialize"); + TopSites._tippyTopProvider.initialized = false; + sandbox.stub(TopSites._tippyTopProvider, "init").resolves(); + + await TopSites.refresh(); + + Assert.ok( + TopSites._tippyTopProvider.init.calledOnce, + "TopSites._tippyTopProvider.init called once" + ); + + info( + "TopSites.refresh should not init the tippyTopProvider if already initialized" + ); + TopSites._tippyTopProvider.initialized = true; + TopSites._tippyTopProvider.init.resetHistory(); + + await TopSites.refresh(); + + Assert.ok( + TopSites._tippyTopProvider.init.notCalled, + "tippyTopProvider not initted again" + ); + + info("TopSites.refresh should broadcast TOP_SITES_UPDATED"); + TopSites.store.dispatch.resetHistory(); + sandbox.stub(TopSites, "getLinksWithDefaults").resolves([]); + + await TopSites.refresh({ broadcast: true }); + + Assert.ok(TopSites.store.dispatch.calledOnce, "dispatch called once"); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.BroadcastToContent({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_dispatch() { + let sandbox = sinon.createSandbox(); + + info("TopSites.refresh should dispatch an action with the links returned"); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + await TopSites.refresh({ broadcast: true }); + let reference = FAKE_LINKS.map(site => + Object.assign({}, site, { + hostname: shortURL(site), + typedBonus: true, + }) + ); + + Assert.ok(TopSites.store.dispatch.calledOnce, "Store.dispatch called once"); + Assert.equal( + TopSites.store.dispatch.firstCall.args[0].type, + at.TOP_SITES_UPDATED + ); + Assert.deepEqual( + TopSites.store.dispatch.firstCall.args[0].data.links, + reference + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_empty_slots() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.refresh should handle empty slots in the resulting top sites array" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + gGetTopSitesStub.resolves([FAKE_LINKS[0]]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + null, + null, + FAKE_LINKS[1], + null, + null, + null, + null, + null, + FAKE_LINKS[2], + ]); + + await TopSites.refresh({ broadcast: true }); + + Assert.ok(TopSites.store.dispatch.calledOnce, "Store.dispatch called once"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_to_preloaded() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.refresh should dispatch AlsoToPreloaded when broadcast is false" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + gGetTopSitesStub.resolves([]); + await TopSites.refresh({ broadcast: false }); + + Assert.ok(TopSites.store.dispatch.calledOnce, "Store.dispatch called once"); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.AlsoToPreloaded({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ) + ); + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_init_storage() { + let sandbox = sinon.createSandbox(); + + info("TopSites.refresh should not init storage of it's already initialized"); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + TopSites._storage.initialized = true; + + await TopSites.refresh({ broadcast: false }); + + Assert.ok( + TopSites._storage.init.notCalled, + "TopSites._storage.init was not called." + ); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_handles_indexedDB_errors() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.refresh should dispatch AlsoToPreloaded when broadcast is false" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + TopSites._storage.get.throws(new Error()); + + try { + await TopSites.refresh({ broadcast: false }); + Assert.ok(true, "refresh should have succeeded"); + } catch (e) { + Assert.ok(false, "Should not have thrown"); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_updateSectionPrefs_on_UPDATE_SECTION_PREFS() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.onAction should call updateSectionPrefs on UPDATE_SECTION_PREFS" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "updateSectionPrefs"); + TopSites.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topsites" }, + }); + + Assert.ok( + TopSites.updateSectionPrefs.calledOnce, + "TopSites.updateSectionPrefs called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_updateSectionPrefs_dispatch_TOP_SITES_PREFS_UPDATED() { + let sandbox = sinon.createSandbox(); + + info("TopSites.updateSectionPrefs should dispatch TOP_SITES_PREFS_UPDATED"); + + let cleanup = stubTopSites(sandbox); + await TopSites.updateSectionPrefs({ collapsed: true }); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.BroadcastToContent({ + type: at.TOP_SITES_PREFS_UPDATED, + data: { pref: { collapsed: true } }, + }) + ) + ); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_allocatePositions() { + let sandbox = sinon.createSandbox(); + + info("TopSites.allocationPositions should allocate positions and dispatch"); + + let cleanup = stubTopSites(sandbox); + + let sov = { + name: "SOV-20230518215316", + allocations: [ + { + position: 1, + allocation: [ + { + partner: "amp", + percentage: 100, + }, + { + partner: "moz-sales", + percentage: 0, + }, + ], + }, + { + position: 2, + allocation: [ + { + partner: "amp", + percentage: 80, + }, + { + partner: "moz-sales", + percentage: 20, + }, + ], + }, + ], + }; + + sandbox.stub(TopSites._contile, "sov").get(() => sov); + + sandbox.stub(Sampling, "ratioSample"); + Sampling.ratioSample.onCall(0).resolves(0); + Sampling.ratioSample.onCall(1).resolves(1); + + await TopSites.allocatePositions(); + + Assert.ok( + TopSites.store.dispatch.calledOnce, + "TopSites.store.dispatch called once" + ); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { position: 1, assignedPartner: "amp" }, + { position: 2, assignedPartner: "moz-sales" }, + ], + }, + }) + ) + ); + + Sampling.ratioSample.onCall(2).resolves(0); + Sampling.ratioSample.onCall(3).resolves(0); + + await TopSites.allocatePositions(); + + Assert.ok( + TopSites.store.dispatch.calledTwice, + "TopSites.store.dispatch called twice" + ); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { position: 1, assignedPartner: "amp" }, + { position: 2, assignedPartner: "amp" }, + ], + }, + }) + ) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getScreenshotPreview() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.getScreenshotPreview should dispatch preview if request is succesful" + ); + + let cleanup = stubTopSites(sandbox); + await TopSites.getScreenshotPreview("custom", 1234); + + Assert.ok(TopSites.store.dispatch.calledOnce); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: FAKE_SCREENSHOT, url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getScreenshotPreview() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.getScreenshotPreview should return empty string if request fails" + ); + + let cleanup = stubTopSites(sandbox); + Screenshots.getScreenshotForURL.resolves(Promise.resolve(null)); + await TopSites.getScreenshotPreview("custom", 1234); + + Assert.ok(TopSites.store.dispatch.calledOnce); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + Screenshots.getScreenshotForURL.resolves(FAKE_SCREENSHOT); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_onAction_part_1() { + let sandbox = sinon.createSandbox(); + + info("TopSites.onAction should call getScreenshotPreview on PREVIEW_REQUEST"); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "getScreenshotPreview"); + TopSites.onAction({ + type: at.PREVIEW_REQUEST, + data: { url: "foo" }, + meta: { fromTarget: 1234 }, + }); + + Assert.ok( + TopSites.getScreenshotPreview.calledOnce, + "TopSites.getScreenshotPreview called once" + ); + Assert.ok(TopSites.getScreenshotPreview.calledWithExactly("foo", 1234)); + + info("TopSites.onAction should refresh on SYSTEM_TICK"); + sandbox.stub(TopSites, "refresh"); + TopSites.onAction({ type: at.SYSTEM_TICK }); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: false })); + + info( + "TopSites.onAction should call with correct parameters on TOP_SITES_PIN" + ); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(TopSites, "pin"); + + let pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: 7 }, + }; + TopSites.onAction(pinAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWithExactly( + pinAction.data.site, + pinAction.data.index + ) + ); + Assert.ok( + TopSites.pin.calledOnce, + "TopSites.onAction should call pin on TOP_SITES_PIN" + ); + + info( + "TopSites.onAction should unblock a previously blocked top site if " + + "we are now adding it manually via 'Add a Top Site' option" + ); + sandbox.stub(NewTabUtils.blockedLinks, "unblock"); + pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: -1 }, + }; + TopSites.onAction(pinAction); + Assert.ok( + NewTabUtils.blockedLinks.unblock.calledWith({ + url: pinAction.data.site.url, + }) + ); + + info("TopSites.onAction should call insert on TOP_SITES_INSERT"); + sandbox.stub(TopSites, "insert"); + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + TopSites.onAction(addAction); + Assert.ok(TopSites.insert.calledOnce, "TopSites.insert called once"); + + info( + "TopSites.onAction should call unpin with correct parameters " + + "on TOP_SITES_UNPIN" + ); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + null, + null, + { url: "foo.com" }, + null, + null, + null, + null, + null, + FAKE_LINKS[0], + ]); + sandbox.stub(NewTabUtils.pinnedLinks, "unpin"); + + let unpinAction = { + type: at.TOP_SITES_UNPIN, + data: { site: { url: "foo.com" } }, + }; + TopSites.onAction(unpinAction); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.unpin.calledWith(unpinAction.data.site)); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_onAction_part_2() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.onAction should call refresh without a target if we clear " + + "history with PLACES_HISTORY_CLEARED" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "refresh"); + TopSites.onAction({ type: at.PLACES_HISTORY_CLEARED }); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: true })); + + TopSites.refresh.resetHistory(); + + info( + "TopSites.onAction should call refresh without a target " + + "if we remove a Topsite from history" + ); + TopSites.onAction({ type: at.PLACES_LINKS_DELETED }); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: true })); + + info("TopSites.onAction should call init on INIT action"); + TopSites.onAction({ type: at.PLACES_LINKS_DELETED }); + sandbox.stub(TopSites, "init"); + TopSites.onAction({ type: at.INIT }); + Assert.ok(TopSites.init.calledOnce, "TopSites.init called once"); + + info("TopSites.onAction should call refresh on PLACES_LINK_BLOCKED action"); + TopSites.refresh.resetHistory(); + await TopSites.onAction({ type: at.PLACES_LINK_BLOCKED }); + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: true })); + + info("TopSites.onAction should call refresh on PLACES_LINKS_CHANGED action"); + TopSites.refresh.resetHistory(); + await TopSites.onAction({ type: at.PLACES_LINKS_CHANGED }); + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: false })); + + info( + "TopSites.onAction should call pin with correct args on " + + "TOP_SITES_INSERT without an index specified" + ); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.bar", label: "foo" } }, + }; + TopSites.onAction(addAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(addAction.data.site, 0)); + + info( + "TopSites.onAction should call pin with correct args on " + + "TOP_SITES_INSERT" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + let dropAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.bar", label: "foo" }, index: 3 }, + }; + TopSites.onAction(dropAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(dropAction.data.site, 3)); + + // TopSites.init needs to actually run in order to register the observers that'll + // be removed in the following UNINIT test, otherwise uninit will throw. + TopSites.init.restore(); + TopSites.init(); + + info("TopSites.onAction should remove the expiration filter on UNINIT"); + sandbox.stub(PageThumbs, "removeExpirationFilter"); + TopSites.onAction({ type: "UNINIT" }); + Assert.ok( + PageThumbs.removeExpirationFilter.calledOnce, + "PageThumbs.removeExpirationFilter called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_onAction_part_3() { + let sandbox = sinon.createSandbox(); + + let cleanup = stubTopSites(sandbox); + + info( + "TopSites.onAction should call updatePinnedSearchShortcuts " + + "on UPDATE_PINNED_SEARCH_SHORTCUTS action" + ); + sandbox.stub(TopSites, "updatePinnedSearchShortcuts"); + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + await TopSites.onAction({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { addedShortcuts }, + }); + Assert.ok( + TopSites.updatePinnedSearchShortcuts.calledOnce, + "TopSites.updatePinnedSearchShortcuts called once" + ); + + info( + "TopSites.onAction should refresh from Contile on " + + "SHOW_SPONSORED_PREF if Contile is enabled" + ); + sandbox.spy(TopSites._contile, "refresh"); + let prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF }, + }; + sandbox.stub(NimbusFeatures.newtab, "getVariable").returns(true); + TopSites.onAction(prefChangeAction); + + Assert.ok( + TopSites._contile.refresh.calledOnce, + "TopSites._contile.refresh called once" + ); + + info( + "TopSites.onAction should not refresh from Contile on " + + "SHOW_SPONSORED_PREF if Contile is disabled" + ); + NimbusFeatures.newtab.getVariable.returns(false); + TopSites._contile.refresh.resetHistory(); + TopSites.onAction(prefChangeAction); + + Assert.ok( + !TopSites._contile.refresh.calledOnce, + "TopSites._contile.refresh never called" + ); + + info( + "TopSites.onAction should reset Contile cache prefs " + + "when SHOW_SPONSORED_PREF is false" + ); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 15 * 60); + prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF, value: false }, + }; + NimbusFeatures.newtab.getVariable.returns(true); + TopSites._contile.refresh.resetHistory(); + + TopSites.onAction(prefChangeAction); + Assert.ok(!Services.prefs.prefHasUserValue(CONTILE_CACHE_PREF)); + Assert.ok(!Services.prefs.prefHasUserValue(CONTILE_CACHE_LAST_FETCH_PREF)); + Assert.ok( + !Services.prefs.prefHasUserValue(CONTILE_CACHE_VALID_FOR_SECONDS_PREF) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_insert_part_1() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + let cleanup = stubTopSites(sandbox); + + info("TopSites.insert should pin site in first slot of empty pinned list"); + Screenshots.getScreenshotForURL.resolves(Promise.resolve(null)); + await TopSites.getScreenshotPreview("custom", 1234); + + Assert.ok(TopSites.store.dispatch.calledOnce); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + Screenshots.getScreenshotForURL.resolves(FAKE_SCREENSHOT); + + { + info( + "TopSites.insert should pin site in first slot of pinned list with " + + "empty first slot" + ); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + let site = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ data: { site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move a pinned site in first slot to the " + + "next slot: part 1" + ); + let site1 = { url: "example.com" }; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [site1]); + let site = { url: "foo.bar", label: "foo" }; + + await TopSites.insert({ data: { site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move a pinned site in first slot to the " + + "next slot: part 2" + ); + let site1 = { url: "example.com" }; + let site2 = { url: "example.org" }; + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [site1, null, site2]); + let site = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ data: { site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_insert_part_2() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + let cleanup = stubTopSites(sandbox); + + { + info( + "TopSites.insert should unpin the last site if all slots are " + + "already pinned" + ); + let site1 = { url: "example.com" }; + let site2 = { url: "example.org" }; + let site3 = { url: "example.net" }; + let site4 = { url: "example.biz" }; + let site5 = { url: "example.info" }; + let site6 = { url: "example.news" }; + let site7 = { url: "example.lol" }; + let site8 = { url: "example.golf" }; + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [site1, site2, site3, site4, site5, site6, site7, site8]); + TopSites.store.state.Prefs.values.topSitesRows = 1; + let site = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ data: { site } }); + Assert.equal( + NewTabUtils.pinnedLinks.pin.callCount, + 8, + "NewTabUtils.pinnedLinks.pin called 8 times" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 2)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site3, 3)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site4, 4)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site5, 5)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site6, 6)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site7, 7)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info("TopSites.insert should trigger refresh on TOP_SITES_INSERT"); + sandbox.stub(TopSites, "refresh"); + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + await TopSites.insert(addAction); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + } + + { + info("TopSites.insert should correctly handle different index values"); + let index = -1; + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index, site } }; + + await TopSites.insert(action); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + + index = undefined; + await TopSites.insert(action); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_insert_part_3() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + let cleanup = stubTopSites(sandbox); + + { + info("TopSites.insert should pin site in specified slot that is free"); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + + await TopSites.insert({ data: { index: 2, site, draggedFromIndex: 0 } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move a pinned site in specified slot " + + "to the next slot" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + + await TopSites.insert({ data: { index: 2, site, draggedFromIndex: 3 } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWith({ url: "example.com" }, 3) + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move pinned sites in the direction " + + "of the dragged site" + ); + + let site1 = { url: "foo.bar", label: "foo" }; + let site2 = { url: "example.com", label: "example" }; + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, null, site2]); + + await TopSites.insert({ + data: { index: 2, site: site1, draggedFromIndex: 0 }, + }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 2)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 1)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + + await TopSites.insert({ + data: { index: 2, site: site1, draggedFromIndex: 5 }, + }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 2)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 3)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info("TopSites.insert should not insert past the visible top sites"); + let site1 = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ + data: { index: 42, site: site1, draggedFromIndex: 0 }, + }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.notCalled, + "NewTabUtils.pinnedLinks.pin wasn't called" + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_pin_part_1() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(TopSites.pinnedCache, "request"); + let cleanup = stubTopSites(sandbox); + + { + info("TopSites.pin should pin site in specified slot empty pinned list"); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + Assert.ok( + TopSites.pinnedCache.request.notCalled, + "TopSites.pinnedCache.request not called" + ); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.called, + "NewTabUtils.pinnedLinks.pin called" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should lookup the link object to update the custom " + + "screenshot" + ); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + Assert.ok( + TopSites.pinnedCache.request.notCalled, + "TopSites.pinnedCache.request not called" + ); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + TopSites.pinnedCache.request.called, + "TopSites.pinnedCache.request called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should lookup the link object to update the custom " + + "screenshot when the custom screenshot is initially null" + ); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: null, + }; + Assert.ok( + TopSites.pinnedCache.request.notCalled, + "TopSites.pinnedCache.request not called" + ); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + TopSites.pinnedCache.request.called, + "TopSites.pinnedCache.request called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should not do a link object lookup if custom " + + "screenshot field is not set" + ); + let site = { url: "foo.bar", label: "foo" }; + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + !TopSites.pinnedCache.request.called, + "TopSites.pinnedCache.request never called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should pin site in specified slot of pinned " + + "list that is free" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_pin_part_2() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info("TopSites.pin should save the searchTopSite attribute if set"); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo", searchTopSite: true }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.firstCall.args[0].searchTopSite); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.pin should NOT move a pinned site in specified " + + "slot to the next slot" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.pin should properly update LinksCache object " + + "properties between migrations" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let cleanup = stubTopSites(sandbox); + let pinnedLinks = await TopSites.pinnedCache.request(); + Assert.equal(pinnedLinks.length, 1); + TopSites.pinnedCache.expire(); + + pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo"); + + pinnedLinks = await TopSites.pinnedCache.request(); + Assert.equal(pinnedLinks[0].screenshot, "foo"); + + // Force cache expiration in order to trigger a migration of objects + TopSites.pinnedCache.expire(); + pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar"); + + pinnedLinks = await TopSites.pinnedCache.request(); + Assert.equal(pinnedLinks[0].screenshot, "bar"); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task(async function test_pin_part_3() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(TopSites, "insert"); + + { + info("TopSites.pin should call insert if index < 0"); + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index: -1, site } }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin(action); + + Assert.ok(TopSites.insert.calledOnce, "TopSites.insert called once"); + Assert.ok(TopSites.insert.calledWithExactly(action)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.insert.resetHistory(); + await cleanup(); + } + + { + info("TopSites.pin should not call insert if index == 0"); + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index: 0, site } }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin(action); + + Assert.ok(!TopSites.insert.called, "TopSites.insert not called"); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + { + info("TopSites.pin should trigger refresh on TOP_SITES_PIN"); + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "refresh"); + let pinExistingAction = { + type: at.TOP_SITES_PIN, + data: { site: FAKE_LINKS[4], index: 4 }, + }; + + await TopSites.pin(pinExistingAction); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task(async function test_integration() { + let sandbox = sinon.createSandbox(); + + info("Test adding a pinned site and removing it with actions"); + let cleanup = stubTopSites(sandbox); + + let resolvers = []; + TopSites.store.dispatch = sandbox.stub().callsFake(() => { + resolvers.shift()(); + }); + TopSites._startedUp = true; + sandbox.stub(TopSites, "_fetchScreenshot"); + + let forDispatch = action => + new Promise(resolve => { + resolvers.push(resolve); + TopSites.onAction(action); + }); + + TopSites._requestRichIcon = sandbox.stub(); + let url = "https://pin.me"; + sandbox.stub(NewTabUtils.pinnedLinks, "pin").callsFake(link => { + NewTabUtils.pinnedLinks.links.push(link); + }); + + await forDispatch({ type: at.TOP_SITES_INSERT, data: { site: { url } } }); + NewTabUtils.pinnedLinks.links.pop(); + await forDispatch({ type: at.PLACES_LINK_BLOCKED }); + + Assert.ok( + TopSites.store.dispatch.calledTwice, + "TopSites.store.dispatch called twice" + ); + Assert.equal( + TopSites.store.dispatch.firstCall.args[0].data.links[0].url, + url + ); + Assert.equal( + TopSites.store.dispatch.secondCall.args[0].data.links[0].url, + FAKE_LINKS[0].url + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_improvesearch_noDefaultSearchTile_experiment() { + let sandbox = sinon.createSandbox(); + const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile"; + + sandbox.stub(SearchService.prototype, "getDefault").resolves({ + identifier: "google", + searchForm: "google.com", + }); + + { + info( + "TopSites.getLinksWithDefaults should filter out alexa top 5 " + + "search from the default sites" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + let top5Test = [ + "https://google.com", + "https://search.yahoo.com", + "https://yahoo.com", + "https://bing.com", + "https://ask.com", + "https://duckduckgo.com", + ]; + + gGetTopSitesStub.resolves([ + { url: "https://amazon.com" }, + ...top5Test.map(url => ({ url })), + ]); + + const urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok( + urlsReturned.includes("https://amazon.com"), + "amazon included in default links" + ); + top5Test.forEach(url => + Assert.ok(!urlsReturned.includes(url), `Should not include ${url}`) + ); + + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should not filter out alexa, default " + + "search from the query results if the experiment pref is off" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = false; + + gGetTopSitesStub.resolves([ + { url: "https://google.com" }, + { url: "https://foo.com" }, + { url: "https://duckduckgo" }, + ]); + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + + Assert.ok(urlsReturned.includes("https://google.com")); + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should filter out the current " + + "default search from the default sites" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + sandbox.stub(TopSites, "_currentSearchHostname").get(() => "amazon"); + TopSites.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(!urlsReturned.includes("https://amazon.com")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should not filter out current " + + "default search from pinned sites even if it matches the current " + + "default search" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "google.com" }]); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(urlsReturned.includes("google.com")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task( + async function test_improvesearch_noDefaultSearchTile_experiment_part_2() { + let sandbox = sinon.createSandbox(); + const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile"; + + sandbox.stub(SearchService.prototype, "getDefault").resolves({ + identifier: "google", + searchForm: "google.com", + }); + + sandbox.stub(TopSites, "refresh"); + + { + info( + "TopSites.getLinksWithDefaults should call refresh and set " + + "._currentSearchHostname to the new engine hostname when the " + + "default search engine has been set" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + TopSites.observe( + null, + "browser-search-engine-modified", + "engine-default" + ); + Assert.equal(TopSites._currentSearchHostname, "duckduckgo"); + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites.refresh.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should call refresh when the " + + "experiment pref has changed" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true }, + }); + Assert.ok( + TopSites.refresh.calledOnce, + "TopSites.refresh was called once" + ); + + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false }, + }); + Assert.ok( + TopSites.refresh.calledTwice, + "TopSites.refresh was called twice" + ); + + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites.refresh.resetHistory(); + await cleanup(); + } + + sandbox.restore(); + } +); + +// eslint-disable-next-line max-statements +add_task(async function test_improvesearch_topSitesSearchShortcuts() { + let sandbox = sinon.createSandbox(); + let searchEngines = [{ aliases: ["@google"] }, { aliases: ["@amazon"] }]; + sandbox + .stub(SearchService.prototype, "getAppProvidedEngines") + .resolves(searchEngines); + sandbox.stub(NewTabUtils.pinnedLinks, "pin").callsFake((site, index) => { + NewTabUtils.pinnedLinks.links[index] = site; + }); + + let prepTopSites = () => { + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = true; + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF] = + "google,amazon"; + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = ""; + }; + + { + info( + "TopSites should updateCustomSearchShortcuts when experiment " + + "pref is turned on" + ); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; + sandbox.spy(TopSites, "updateCustomSearchShortcuts"); + + // turn the experiment on + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true }, + }); + + Assert.ok( + TopSites.updateCustomSearchShortcuts.calledOnce, + "TopSites.updateCustomSearchShortcuts called once" + ); + TopSites.updateCustomSearchShortcuts.restore(); + await cleanup(); + } + + { + info( + "TopSites should filter out default top sites that match a " + + "hostname of a search shortcut if previously blocked" + ); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + TopSites.refreshDefaults("https://amazon.ca"); + sandbox + .stub(NewTabUtils.blockedLinks, "links") + .value([{ url: "https://amazon.com" }]); + sandbox.stub(NewTabUtils.blockedLinks, "isBlocked").callsFake(site => { + return NewTabUtils.blockedLinks.links[0].url === site.url; + }); + + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(!urlsReturned.includes("https://amazon.ca")); + await cleanup(); + } + + { + info("TopSites should update frecent search topsite icon"); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + sandbox.stub(TopSites._tippyTopProvider, "processSite").callsFake(site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }); + gGetTopSitesStub.resolves([{ url: "https://google.com" }]); + + let urlsReturned = await TopSites.getLinksWithDefaults(); + + let defaultSearchTopsite = urlsReturned.find( + s => s.url === "https://google.com" + ); + Assert.ok(defaultSearchTopsite.searchTopSite); + Assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); + Assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites._tippyTopProvider.processSite.restore(); + await cleanup(); + } + + { + info("TopSites should update default search topsite icon"); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + sandbox.stub(TopSites._tippyTopProvider, "processSite").callsFake(site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + TopSites.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + + let urlsReturned = await TopSites.getLinksWithDefaults(); + + let defaultSearchTopsite = urlsReturned.find( + s => s.url === "https://amazon.com" + ); + Assert.ok(defaultSearchTopsite.searchTopSite); + Assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); + Assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites._tippyTopProvider.processSite.restore(); + TopSites.store.dispatch.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites should dispatch UPDATE_SEARCH_SHORTCUTS on " + + "updateCustomSearchShortcuts" + ); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + TopSites.store.state.Prefs.values[ + "improvesearch.noDefaultSearchTile" + ] = true; + await TopSites.updateCustomSearchShortcuts(); + Assert.ok( + TopSites.store.dispatch.calledOnce, + "TopSites.store.dispatch called once" + ); + Assert.ok( + TopSites.store.dispatch.calledWith({ + data: { + searchShortcuts: [ + { + keyword: "@google", + shortURL: "google", + url: "https://google.com", + backgroundColor: undefined, + smallFavicon: + "chrome://activity-stream/content/data/content/tippytop/favicons/google-com.ico", + tippyTopIcon: + "chrome://activity-stream/content/data/content/tippytop/images/google-com@2x.png", + }, + { + keyword: "@amazon", + shortURL: "amazon", + url: "https://amazon.com", + backgroundColor: undefined, + smallFavicon: + "chrome://activity-stream/content/data/content/tippytop/favicons/amazon.ico", + tippyTopIcon: + "chrome://activity-stream/content/data/content/tippytop/images/amazon@2x.png", + }, + ], + }, + meta: { + from: "ActivityStream:Main", + to: "ActivityStream:Content", + isStartup: false, + }, + type: "UPDATE_SEARCH_SHORTCUTS", + }) + ); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task(async function test_updatePinnedSearchShortcuts() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.stub(NewTabUtils.pinnedLinks, "unpin"); + + { + info( + "TopSites.updatePinnedSearchShortcuts should unpin a " + + "shortcut in deletedShortcuts" + ); + let cleanup = stubTopSites(sandbox); + + let deletedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let addedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [ + null, + null, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.notCalled, + "NewTabUtils.pinnedLinks.pin not called" + ); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledWith({ + url: "https://google.com", + }) + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.updatePinnedSearchShortcuts should pin a shortcut " + + "in addedShortcuts" + ); + let cleanup = stubTopSites(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let deletedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [ + null, + null, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.notCalled, + "NewTabUtils.pinnedLinks.unpin not called" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWith( + { + label: "google", + searchTopSite: true, + searchVendor: "google", + url: "https://google.com", + }, + 0 + ) + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.updatePinnedSearchShortcuts should pin and unpin " + + "in the same action" + ); + let cleanup = stubTopSites(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + { + url: "https://ebay.com", + searchVendor: "ebay", + label: "ebay", + searchTopSite: true, + }, + ]; + let deletedShortcuts = [ + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]; + + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [ + { url: "https://foo.com" }, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.updatePinnedSearchShortcuts should pin a shortcut in " + + "addedShortcuts even if pinnedLinks is full" + ); + let cleanup = stubTopSites(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let deletedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => FAKE_LINKS); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + + Assert.ok( + NewTabUtils.pinnedLinks.unpin.notCalled, + "NewTabUtils.pinnedLinks.unpin not called" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWith( + { label: "google", searchTopSite: true, url: "https://google.com" }, + 0 + ), + "NewTabUtils.pinnedLinks.unpin not called" + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + sandbox.restore(); +}); + +// eslint-disable-next-line max-statements +add_task(async function test_ContileIntegration() { + let sandbox = sinon.createSandbox(); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + sandbox.stub(NimbusFeatures.newtab, "getVariable").returns(true); + + let fetchStub; + + let prepTopSites = () => { + TopSites.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; + fetchStub = sandbox.stub(TopSites, "fetch"); + function cleanupPrep() { + TopSites._contile._sites = []; + fetchStub.restore(); + } + return cleanupPrep; + }; + + { + info("TopSites._fetchSites should fetch sites from Contile"); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 2); + await cleanup(); + cleanupPrep(); + } + + { + info("TopSites._fetchSites should call allocatePositions"); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + sandbox.stub(TopSites, "allocatePositions").resolves(); + await TopSites._contile.refresh(); + + Assert.ok( + TopSites.allocatePositions.calledOnce, + "TopSites.allocatePositions called once" + ); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should fetch SOV (Share-of-Voice) " + + "settings from Contile" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + let sov = { + name: "SOV-20230518215316", + allocations: [ + { + position: 1, + allocation: [ + { + partner: "foo", + percentage: 100, + }, + { + partner: "bar", + percentage: 0, + }, + ], + }, + { + position: 2, + allocation: [ + { + partner: "foo", + percentage: 80, + }, + { + partner: "bar", + percentage: 20, + }, + ], + }, + ], + }; + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + sov: btoa(JSON.stringify(sov)), + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.deepEqual(TopSites._contile.sov, sov); + Assert.equal(TopSites._contile.sites.length, 2); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should not fetch from Contile if " + + "it's not enabled" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.returns(false); + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetchStub.notCalled, "TopSites.fetch was not called"); + Assert.ok(!fetched); + Assert.equal(TopSites._contile.sites.length, 0); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should still return two tiles when Contile " + + "provides more than 2 tiles and filtering results in more than 2 tiles" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.onCall(0).returns(true); + NimbusFeatures.newtab.getVariable.onCall(1).returns(true); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + { + url: "https://test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + // Both "foo" and "bar" should be filtered + Assert.equal(TopSites._contile.sites.length, 2); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + Assert.equal(TopSites._contile.sites[1].url, "https://test1.com"); + + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should still return two tiles with " + + "replacement if the Nimbus variable was unset" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.onCall(0).returns(true); + NimbusFeatures.newtab.getVariable.onCall(1).returns(undefined); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 2); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + Assert.equal(TopSites._contile.sites[1].url, "https://test1.com"); + + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info("TopSites._fetchSites should filter the blocked sponsors"); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + // Both "foo" and "bar" should be filtered + Assert.equal(TopSites._contile.sites.length, 1); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should return false when Contile returns " + + "with error status and no values are stored in cache prefs" + ); + NimbusFeatures.newtab.getVariable.returns(true); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 0); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: false, + status: 500, + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should return false when Contile " + + "returns with error status and cached tiles are expried" + ); + NimbusFeatures.newtab.getVariable.returns(true); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + const THIRTY_MINUTES_AGO_IN_SECONDS = + Math.round(Date.now() / 1000) - 60 * 30; + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + THIRTY_MINUTES_AGO_IN_SECONDS + ); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 60 * 15); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + fetchStub.resolves({ + ok: false, + status: 500, + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should handle invalid payload " + + "properly from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + unknown: [], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should handle empty payload properly " + + "from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info("TopSites._fetchSites should handle no content properly from Contile"); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ ok: true, status: 204 }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should set Caching Prefs after " + + "a successful request" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + let tiles = [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles, + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal( + Services.prefs.getStringPref(CONTILE_CACHE_PREF), + JSON.stringify(tiles) + ); + Assert.equal( + Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF), + 11322 + ); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should return cached valid tiles " + + "when Contile returns error status" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + let tiles = [ + { + url: "https://www.test-cached.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1-cached.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + + Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 60 * 15); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + + fetchStub.resolves({ + status: 304, + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 2); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test-cached.com"); + Assert.equal( + TopSites._contile.sites[1].url, + "https://www.test1-cached.com" + ); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should not be successful when contile " + + "returns an error and no valid tiles are cached" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 0); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 0); + + fetchStub.resolves({ + status: 500, + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(!fetched); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should return cached valid tiles " + + "filtering blocked tiles when Contile returns error status" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + let tiles = [ + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.test1-cached.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 60 * 15); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + + fetchStub.resolves({ + status: 304, + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 1); + Assert.equal( + TopSites._contile.sites[0].url, + "https://www.test1-cached.com" + ); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should still return 3 tiles when nimbus " + + "variable overrides max num of sponsored contile tiles" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + sandbox.stub(NimbusFeatures.pocketNewtab, "getVariable").returns(3); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + { + url: "https://test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 3); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + Assert.equal(TopSites._contile.sites[1].url, "https://test1.com"); + Assert.equal(TopSites._contile.sites[2].url, "https://test2.com"); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); diff --git a/browser/components/topsites/test/unit/xpcshell.toml b/browser/components/topsites/test/unit/xpcshell.toml new file mode 100644 index 0000000000..98b0fc5360 --- /dev/null +++ b/browser/components/topsites/test/unit/xpcshell.toml @@ -0,0 +1,4 @@ +[DEFAULT] +firefox-appdir = "browser" + +["test_top_sites.js"] diff --git a/browser/components/touchbar/MacTouchBar.sys.mjs b/browser/components/touchbar/MacTouchBar.sys.mjs index 6588920f5e..1969317e12 100644 --- a/browser/components/touchbar/MacTouchBar.sys.mjs +++ b/browser/components/touchbar/MacTouchBar.sys.mjs @@ -134,7 +134,7 @@ var gBuiltInInputs = { type: kInputTypes.BUTTON, callback: () => { let win = lazy.BrowserWindowTracker.getTopWindow(); - win.SidebarUI.toggle(); + win.SidebarController.toggle(); }, }, AddBookmark: { diff --git a/browser/components/translations/content/fullPageTranslationsPanel.inc.xhtml b/browser/components/translations/content/fullPageTranslationsPanel.inc.xhtml index bc0c5b319f..6b3e19538d 100644 --- a/browser/components/translations/content/fullPageTranslationsPanel.inc.xhtml +++ b/browser/components/translations/content/fullPageTranslationsPanel.inc.xhtml @@ -24,7 +24,7 @@ <html:h1 class="translations-panel-header-wrapper"> <html:span id="full-page-translations-panel-header"></html:span> </html:h1> - <hbox class="translations-panel-beta"> + <hbox class="translations-panel-beta" role="image" aria-label="Beta"> <image class="translations-panel-beta-icon"></image> </hbox> <toolbarbutton id="translations-panel-settings" @@ -49,7 +49,7 @@ flex="1" value="detect" size="large" - aria-labelledby="translations-panel-from-label" + aria-labelledby="full-page-translations-panel-from-label" oncommand="FullPageTranslationsPanel.onChangeFromLanguage(event)"> <menupopup id="full-page-translations-panel-from-menupopup" class="translations-panel-language-menupopup-from"> @@ -63,7 +63,7 @@ flex="1" value="detect" size="large" - aria-labelledby="translations-panel-to-label" + aria-labelledby="full-page-translations-panel-to-label" oncommand="FullPageTranslationsPanel.onChangeToLanguage(event)"> <menupopup id="full-page-translations-panel-to-menupopup" class="translations-panel-language-menupopup-to"> @@ -85,7 +85,7 @@ </vbox> </vbox> - <html:moz-button-group class="panel-footer translations-panel-footer"> + <html:moz-button-group class="panel-footer translations-panel-footer translations-panel-button-group"> <button id="full-page-translations-panel-restore-button" class="footer-button" oncommand="FullPageTranslationsPanel.onRestore(event);" diff --git a/browser/components/translations/content/fullPageTranslationsPanel.js b/browser/components/translations/content/fullPageTranslationsPanel.js index eddd3566f1..2875333d61 100644 --- a/browser/components/translations/content/fullPageTranslationsPanel.js +++ b/browser/components/translations/content/fullPageTranslationsPanel.js @@ -1041,8 +1041,6 @@ var FullPageTranslationsPanel = new (class { isFirstUserInteraction = null, } ) { - await window.ensureCustomElements("moz-button-group"); - const { panel, appMenuButton } = this.elements; const openedFromAppMenu = target.id === appMenuButton.id; const { docLangTag } = await this.#getCachedDetectedLanguages(); @@ -1107,10 +1105,6 @@ var FullPageTranslationsPanel = new (class { return; } - const window = - gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; - window.ensureCustomElements("moz-support-link"); - const { button } = this.buttonElements; const { requestedTranslationPair } = diff --git a/browser/components/translations/content/selectTranslationsPanel.inc.xhtml b/browser/components/translations/content/selectTranslationsPanel.inc.xhtml index 8c643ea3f6..287bd65679 100644 --- a/browser/components/translations/content/selectTranslationsPanel.inc.xhtml +++ b/browser/components/translations/content/selectTranslationsPanel.inc.xhtml @@ -8,95 +8,158 @@ type="arrow" role="alertdialog" noautofocus="true" + tabspecific="true" + locationspecific="true" aria-labelledby="translations-panel-header" - orient="vertical" - onpopupshown="SelectTranslationsPanel.handlePanelPopupShownEvent(event)" - onpopuphidden="SelectTranslationsPanel.handlePanelPopupHiddenEvent(event)"> + orient="vertical"> <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"> + <hbox class="translations-panel-beta" role="image" aria-label="Beta"> <image id="select-translations-panel-beta-icon" class="translations-panel-beta-icon"> </image> </hbox> - <toolbarbutton id="select-translations-panel-settings" + <toolbarbutton id="select-translations-panel-settings-button" class="panel-info-button translations-panel-settings-gear-icon" data-l10n-id="translations-panel-settings-button" - closemenu="none" /> + tabindex="0" + 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 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"> + <html:div id="select-translations-panel-init-failure-content" + class="translations-panel-content" + hidden="true"> + <html:moz-message-bar id="select-translations-panel-init-failure-message-bar" + type="error" + class="select-translations-panel-message-bar" + data-l10n-id="select-translations-panel-init-failure-message" + data-l10n-attrs="message"> + </html:moz-message-bar> + </html:div> + <html:div id="select-translations-panel-unsupported-language-content" hidden="true"> + <vbox flex="1" class="select-translations-panel-content"> + <html:moz-message-bar id="select-translations-panel-unsupported-language-message-bar" + type="info" + class="select-translations-panel-message-bar" + data-l10n-id="select-translations-panel-unsupported-language-message-unknown" + data-l10n-attrs="message"> + </html:moz-message-bar> + <label id="select-translations-panel-try-another-language-label" + class="select-translations-panel-label" + data-l10n-id="select-translations-panel-try-another-language-label"> + </label> + <menulist id="select-translations-panel-try-another-language" + flex="1" + value="" + size="large" + data-l10n-id="translations-panel-choose-language" + aria-labelledby="select-translations-panel-try-another-language-label" + noinitialselection="true"> + <menupopup id="select-translations-panel-try-another-language-menupopup" + class="translations-panel-language-menupopup-from"> + <!-- The list of <menuitem> will be dynamically inserted. --> + </menupopup> + </menulist> + </vbox> + </html:div> + <html:div id="select-translations-panel-main-content"> + <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"> + <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="" + size="large" + data-l10n-id="translations-panel-choose-language" + aria-labelledby="select-translations-panel-to-label" + noinitialselection="true"> + <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> + </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> + <html:moz-message-bar id="select-translations-panel-translation-failure-message-bar" + type="error" + hidden="true" + data-l10n-id="select-translations-panel-translation-failure-message" + data-l10n-attrs="message"> + </html:moz-message-bar> + </vbox> + </html:div> + <html:div id="select-translations-panel-footer" + class="panel-footer translations-panel-footer"> <button id="select-translations-panel-copy-button" - class="footer-button select-translations-panel-button select-translations-panel-copy-button" + class="footer-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> + <html:moz-button-group id="select-translations-panel-footer-button-group" + class="translations-panel-button-group"> + <button id="select-translations-panel-cancel-button" + class="footer-button" + hidden="true" + data-l10n-id="select-translations-panel-cancel-button"> + </button> + <button id="select-translations-panel-done-button-secondary" + hidden="true" + class="footer-button" + data-l10n-id="select-translations-panel-done-button"> + </button> + <button id="select-translations-panel-translate-full-page-button" + class="footer-button" + data-l10n-id="select-translations-panel-translate-full-page-button"> + </button> + <button id="select-translations-panel-done-button-primary" + class="footer-button" + data-l10n-id="select-translations-panel-done-button" + default="true"> + </button> + <button id="select-translations-panel-translate-button" + class="footer-button" + data-l10n-id="select-translations-panel-translate-button" + hidden="true" + default="true" + disabled="true"> + </button> + <button id="select-translations-panel-try-again-button" + class="footer-button" + data-l10n-id="select-translations-panel-try-again-button" + hidden="true" + default="true"> + </button> + </html:moz-button-group> + </html:div> </panel> </html:template> diff --git a/browser/components/translations/content/selectTranslationsPanel.js b/browser/components/translations/content/selectTranslationsPanel.js index bb825eaefa..36452d4cc0 100644 --- a/browser/components/translations/content/selectTranslationsPanel.js +++ b/browser/components/translations/content/selectTranslationsPanel.js @@ -16,6 +16,13 @@ ChromeUtils.defineESModuleGetters(this, { Translator: "chrome://global/content/translations/Translator.mjs", }); +XPCOMUtils.defineLazyServiceGetter( + this, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + /** * This singleton class controls the SelectTranslations panel. * @@ -122,18 +129,28 @@ var SelectTranslationsPanel = new (class { #lazyElements; /** - * The internal state of the SelectTranslationsPanel. + * Set to true the first time event listeners are initialized. * - * @type {SelectTranslationsPanelState} + * @type {boolean} */ - #translationState = { phase: "closed" }; + #eventListenersInitialized = false; + + /** + * This value is true if this page does not allow Full Page Translations, + * e.g. PDFs, reader mode, internal Firefox pages. + * + * Many of these are cases where the SelectTranslationsPanel is available + * even though the FullPageTranslationsPanel is not, so this helps inform + * whether the translate-full-page button should be allowed in this context. + */ + #isFullPageTranslationsRestrictedForPage = true; /** - * The Translator for the current language pair. + * The internal state of the SelectTranslationsPanel. * - * @type {Translator} + * @type {SelectTranslationsPanelState} */ - #translator; + #translationState = { phase: "closed" }; /** * An Id that increments with each translation, used to help keep track @@ -171,18 +188,37 @@ var SelectTranslationsPanel = new (class { TranslationsPanelShared.defineLazyElements(document, this.#lazyElements, { betaIcon: "select-translations-panel-beta-icon", + cancelButton: "select-translations-panel-cancel-button", copyButton: "select-translations-panel-copy-button", - doneButton: "select-translations-panel-done-button", + doneButtonPrimary: "select-translations-panel-done-button-primary", + doneButtonSecondary: "select-translations-panel-done-button-secondary", fromLabel: "select-translations-panel-from-label", fromMenuList: "select-translations-panel-from", fromMenuPopup: "select-translations-panel-from-menupopup", header: "select-translations-panel-header", + initFailureContent: "select-translations-panel-init-failure-content", + initFailureMessageBar: + "select-translations-panel-init-failure-message-bar", + mainContent: "select-translations-panel-main-content", + settingsButton: "select-translations-panel-settings-button", textArea: "select-translations-panel-text-area", toLabel: "select-translations-panel-to-label", toMenuList: "select-translations-panel-to", toMenuPopup: "select-translations-panel-to-menupopup", + translateButton: "select-translations-panel-translate-button", translateFullPageButton: "select-translations-panel-translate-full-page-button", + translationFailureMessageBar: + "select-translations-panel-translation-failure-message-bar", + tryAgainButton: "select-translations-panel-try-again-button", + tryAnotherSourceMenuList: + "select-translations-panel-try-another-language", + tryAnotherSourceMenuPopup: + "select-translations-panel-try-another-language-menupopup", + unsupportedLanguageContent: + "select-translations-panel-unsupported-language-content", + unsupportedLanguageMessageBar: + "select-translations-panel-unsupported-language-message-bar", }); } @@ -213,12 +249,32 @@ var SelectTranslationsPanel = new (class { // 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; + try { + const actor = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ); + const detectedLanguages = actor.languageState.detectedLanguages; + if (detectedLanguages?.isDocLangTagSupported) { + return detectedLanguages.docLangTag; + } + } catch (error) { + // Failed to retrieve the Translations actor to detect the document language. + // This is most likely due to attempting to retrieve the actor in a page that + // is restricted for Full Page Translations, such as a PDF or reader mode, but + // Select Translations is often still available, so we can safely continue to + // the final return fallback. + if ( + !TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser) + ) { + // If we failed to retrieve the TranslationsParent actor on a non-restricted page, + // we should warn about this, because it is unexpected. The SelectTranslationsPanel + // itself will display an error state if this causes a failure, and this will help + // diagnose the issue if this scenario should ever occur. + this.console?.warn( + "Failed to retrieve the TranslationsParent actor on a page where Full Page Translations is not restricted." + ); + this.console?.error(error); + } } // No supported language was found, so return the top detected language @@ -233,19 +289,27 @@ var SelectTranslationsPanel = new (class { * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed. * @returns {Promise<{fromLang?: string, toLang?: string}>} - An object containing the language pair for the translation. * The `fromLang` property is omitted if it is a language that is not currently supported by Firefox Translations. - * The `toLang` property is omitted if it is the same as `fromLang`. */ async getLangPairPromise(textToTranslate) { + if ( + TranslationsParent.isInAutomation() && + !TranslationsParent.isTranslationsEngineMocked() + ) { + // If we are in automation, and the Translations Engine is NOT mocked, then that means + // we are in a test case in which we are not explicitly testing Select Translations, + // and the code to get the supported languages below will not be available. However, + // we still need to ensure that the translate-selection menuitem in the context menu + // is compatible with all code in other tests, so we will return "en" for the purpose + // of being able to localize and display the context-menu item in other test cases. + return { toLang: "en" }; + } + const [fromLang, toLang] = await Promise.all([ SelectTranslationsPanel.getTopSupportedDetectedLanguage(textToTranslate), TranslationsParent.getTopPreferredSupportedToLang(), ]); - return { - fromLang, - // If the fromLang and toLang are the same, discard the toLang. - toLang: fromLang === toLang ? undefined : toLang, - }; + return { fromLang, toLang }; } /** @@ -300,11 +364,49 @@ var SelectTranslationsPanel = new (class { */ async #initializeLanguageMenuLists(langPairPromise) { const { fromLang, toLang } = await langPairPromise; - const { fromMenuList, toMenuList } = this.elements; + const { + fromMenuList, + fromMenuPopup, + toMenuList, + toMenuPopup, + tryAnotherSourceMenuList, + } = this.elements; + await Promise.all([ this.#initializeLanguageMenuList(fromLang, fromMenuList), this.#initializeLanguageMenuList(toLang, toMenuList), + this.#initializeLanguageMenuList(null, tryAnotherSourceMenuList), ]); + + this.#maybeTranslateOnEvents(["keypress"], fromMenuList); + this.#maybeTranslateOnEvents(["keypress"], toMenuList); + + this.#maybeTranslateOnEvents(["popuphidden"], fromMenuPopup); + this.#maybeTranslateOnEvents(["popuphidden"], toMenuPopup); + } + + /** + * Initializes event listeners on the panel class the first time + * this function is called, and is a no-op on subsequent calls. + */ + #initializeEventListeners() { + if (this.#eventListenersInitialized) { + // Event listeners have already been initialized, do nothing. + return; + } + + const { panel, fromMenuList, toMenuList, tryAnotherSourceMenuList } = + this.elements; + + panel.addEventListener("popupshown", this); + panel.addEventListener("popuphidden", this); + + panel.addEventListener("command", this); + fromMenuList.addEventListener("command", this); + toMenuList.addEventListener("command", this); + tryAnotherSourceMenuList.addEventListener("command", this); + + this.#eventListenersInitialized = true; } /** @@ -320,20 +422,60 @@ var SelectTranslationsPanel = new (class { */ async open(event, screenX, screenY, sourceText, langPairPromise) { if (this.#isOpen()) { + await this.#forceReopen( + event, + screenX, + screenY, + sourceText, + langPairPromise + ); return; } - this.#registerSourceText(sourceText); - await this.#ensureLangListsBuilt(); + try { + this.#isFullPageTranslationsRestrictedForPage = + TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser); + this.#initializeEventListeners(); + await this.#ensureLangListsBuilt(); + await Promise.all([ + this.#cachePlaceholderText(), + this.#initializeLanguageMenuLists(langPairPromise), + this.#registerSourceText(sourceText, langPairPromise), + ]); + this.#maybeRequestTranslation(); + } catch (error) { + this.console?.error(error); + this.#changeStateToInitFailure( + event, + screenX, + screenY, + sourceText, + langPairPromise + ); + } - await Promise.all([ - this.#cachePlaceholderText(), - this.#initializeLanguageMenuLists(langPairPromise), - ]); + this.#openPopup(event, screenX, screenY); + } - this.#displayIdlePlaceholder(); - this.#maybeRequestTranslation(); - await this.#openPopup(event, screenX, screenY); + /** + * Forces the panel to close and reopen at the same location. + * + * This should never be called in the regular flow of events, but is good to have in case + * the panel somehow gets into an invalid state. + * + * @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 #forceReopen(event, screenX, screenY, sourceText, langPairPromise) { + this.console?.warn("The SelectTranslationsPanel was forced to reopen."); + this.close(); + this.#changeStateToClosed(); + await this.open(event, screenX, screenY, sourceText, langPairPromise); } /** @@ -343,9 +485,7 @@ var SelectTranslationsPanel = new (class { * @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"); - + #openPopup(event, screenX, screenY) { this.console?.log("Showing SelectTranslationsPanel"); const { panel } = this.elements; panel.openPopupAtScreen(screenX, screenY, /* isContextMenu */ false, event); @@ -356,20 +496,38 @@ var SelectTranslationsPanel = new (class { * on the length of the text. * * @param {string} sourceText - The text to translate. + * @param {Promise<{fromLang?: string, toLang?: string}>} langPairPromise * * @returns {Promise<void>} */ - #registerSourceText(sourceText) { + async #registerSourceText(sourceText, langPairPromise) { const { textArea } = this.elements; - this.#changeStateTo("idle", /* retainEntries */ false, { - sourceText, - }); + const { fromLang, toLang } = await langPairPromise; + const isFromLangSupported = await TranslationsParent.isSupportedAsFromLang( + fromLang + ); + + if (isFromLangSupported) { + this.#changeStateTo("idle", /* retainEntries */ false, { + sourceText, + fromLanguage: fromLang, + toLanguage: toLang, + }); + } else { + this.#changeStateTo("unsupported", /* retainEntries */ false, { + sourceText, + detectedLanguage: fromLang, + toLanguage: toLang, + }); + } if (sourceText.length < SelectTranslationsPanel.textLengthThreshold) { textArea.style.height = SelectTranslationsPanel.shortTextHeight; } else { textArea.style.height = SelectTranslationsPanel.longTextHeight; } + + this.#maybeTranslateOnEvents(["focus"], textArea); } /** @@ -385,24 +543,122 @@ var SelectTranslationsPanel = new (class { } /** - * Handles events when a popup is shown within the panel, including showing - * the panel itself. + * Opens the settings menu popup at the settings button gear-icon. + */ + #openSettingsPopup() { + const { settingsButton } = this.elements; + const popup = settingsButton.ownerDocument.getElementById( + "select-translations-panel-settings-menupopup" + ); + popup.openPopup(settingsButton, "after_start"); + } + + /** + * Opens the "About translation in Firefox" Mozilla support page in a new tab. + */ + onAboutTranslations() { + this.close(); + const window = + gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; + window.openTrustedLinkIn( + "https://support.mozilla.org/kb/website-translation", + "tab", + { + forceForeground: true, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + } + ); + } + + /** + * Opens the Translations section of about:preferences in a new tab. + */ + openTranslationsSettingsPage() { + this.close(); + const window = + gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; + window.openTrustedLinkIn("about:preferences#general-translations", "tab"); + } + + /** + * Handles events when a command event is triggered within the panel. * - * @param {Event} event - The event that triggered the popup to show. + * @param {Element} target - The event target */ - handlePanelPopupShownEvent(event) { - const { panel, fromMenuPopup, toMenuPopup } = this.elements; - switch (event.target.id) { - case panel.id: { - this.#updatePanelUIFromState(); + #handleCommandEvent(target) { + const { + cancelButton, + copyButton, + doneButtonPrimary, + doneButtonSecondary, + fromMenuList, + fromMenuPopup, + settingsButton, + toMenuList, + toMenuPopup, + translateButton, + translateFullPageButton, + tryAgainButton, + tryAnotherSourceMenuList, + tryAnotherSourceMenuPopup, + } = this.elements; + switch (target.id) { + case cancelButton.id: + case doneButtonPrimary.id: + case doneButtonSecondary.id: { + this.close(); break; } + case copyButton.id: { + this.onClickCopyButton(); + break; + } + case fromMenuList.id: case fromMenuPopup.id: { - this.#maybeTranslateOnEvents(["popuphidden"], fromMenuPopup); + this.onChangeFromLanguage(); + break; + } + case settingsButton.id: { + this.#openSettingsPopup(); break; } + case toMenuList.id: case toMenuPopup.id: { - this.#maybeTranslateOnEvents(["popuphidden"], toMenuPopup); + this.onChangeToLanguage(); + break; + } + case translateButton.id: { + this.onClickTranslateButton(); + break; + } + case translateFullPageButton.id: { + this.onClickTranslateFullPageButton(); + break; + } + case tryAgainButton.id: { + this.onClickTryAgainButton(); + break; + } + case tryAnotherSourceMenuList.id: + case tryAnotherSourceMenuPopup.id: { + this.onChangeTryAnotherSourceLanguage(); + break; + } + } + } + + /** + * Handles events when a popup is shown within the panel, including showing + * the panel itself. + * + * @param {Element} target - The event target + */ + #handlePopupShownEvent(target) { + const { panel } = this.elements; + switch (target.id) { + case panel.id: { + this.#updatePanelUIFromState(); break; } } @@ -412,13 +668,44 @@ var SelectTranslationsPanel = new (class { * 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. + * @param {Element} target - The event target */ - handlePanelPopupHiddenEvent(event) { + #handlePopupHiddenEvent(target) { const { panel } = this.elements; - switch (event.target.id) { + switch (target.id) { case panel.id: { this.#changeStateToClosed(); + this.#removeActiveTranslationListeners(); + break; + } + } + } + + /** + * Handles events in the SelectTranslationsPanel. + * + * @param {Event} event - The event to handle. + */ + handleEvent(event) { + let target = event.target; + + // If a menuitem within a menulist is the target, those don't have ids, + // so we want to traverse until we get to a parent element with an id. + while (!target.id && target.parentElement) { + target = target.parentElement; + } + + switch (event.type) { + case "command": { + this.#handleCommandEvent(target); + break; + } + case "popupshown": { + this.#handlePopupShownEvent(target); + break; + } + case "popuphidden": { + this.#handlePopupHiddenEvent(target); break; } } @@ -428,18 +715,138 @@ var SelectTranslationsPanel = new (class { * Handles events when the panels select from-language is changed. */ onChangeFromLanguage() { - const { fromMenuList, toMenuList } = this.elements; - this.#maybeTranslateOnEvents(["blur", "keypress"], fromMenuList); - this.#maybeStealLanguageFrom(toMenuList); + this.#updateConditionalUIEnabledState(); } /** * Handles events when the panels select to-language is changed. */ onChangeToLanguage() { - const { toMenuList, fromMenuList } = this.elements; - this.#maybeTranslateOnEvents(["blur", "keypress"], toMenuList); - this.#maybeStealLanguageFrom(fromMenuList); + this.#updateConditionalUIEnabledState(); + } + + /** + * Handles events when the panel's try-another-source language is changed. + */ + onChangeTryAnotherSourceLanguage() { + const { tryAnotherSourceMenuList, translateButton } = this.elements; + if (tryAnotherSourceMenuList.value) { + translateButton.disabled = false; + } + } + + /** + * Handles events when the panel's copy button is clicked. + */ + onClickCopyButton() { + try { + ClipboardHelper.copyString(this.getTranslatedText()); + } catch (error) { + this.console?.error(error); + return; + } + + this.#checkCopyButton(); + } + + /** + * Handles events when the panel's translate button is clicked. + */ + onClickTranslateButton() { + const { fromMenuList, tryAnotherSourceMenuList } = this.elements; + fromMenuList.value = tryAnotherSourceMenuList.value; + this.#maybeRequestTranslation(); + } + + /** + * Handles events when the panel's translate-full-page button is clicked. + */ + onClickTranslateFullPageButton() { + const { panel } = this.elements; + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + + try { + const actor = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ); + panel.addEventListener( + "popuphidden", + () => + actor.translate( + fromLanguage, + toLanguage, + false // reportAsAutoTranslate + ), + { once: true } + ); + } catch (error) { + // This situation would only occur if the translate-full-page button as invoked + // while Translations actor is not available. the logic within this class explicitly + // hides the button in this case, and this should not be possible under normal conditions, + // but if this button were to somehow still be invoked, the best thing we can do here is log + // an error to the console because the FullPageTranslationsPanel assumes that the actor is available. + this.console?.error(error); + } + + this.close(); + } + + /** + * Handles events when the panel's try-again button is clicked. + */ + onClickTryAgainButton() { + switch (this.phase()) { + case "translation-failure": { + // If the translation failed, we just need to try translating again. + this.#maybeRequestTranslation(); + break; + } + case "init-failure": { + // If the initialization failed, we need to close the panel and try reopening it + // which will attempt to initialize everything again after failure. + const { panel } = this.elements; + const { event, screenX, screenY, sourceText, langPairPromise } = + this.#translationState; + + panel.addEventListener( + "popuphidden", + () => this.open(event, screenX, screenY, sourceText, langPairPromise), + { once: true } + ); + + this.close(); + break; + } + default: { + this.console?.error( + `Unexpected state "${this.phase()}" on try-again button click.` + ); + } + } + } + + /** + * Changes the copy button's visual icon to checked, and its localized text to "Copied". + */ + #checkCopyButton() { + const { copyButton } = this.elements; + copyButton.classList.add("copied"); + document.l10n.setAttributes( + copyButton, + "select-translations-panel-copy-button-copied" + ); + } + + /** + * Changes the copy button's visual icon to unchecked, and its localized text to "Copy". + */ + #uncheckCopyButton() { + const { copyButton } = this.elements; + copyButton.classList.remove("copied"); + document.l10n.setAttributes( + copyButton, + "select-translations-panel-copy-button" + ); } /** @@ -455,21 +862,6 @@ var SelectTranslationsPanel = new (class { } /** - * 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. * @@ -525,21 +917,6 @@ var SelectTranslationsPanel = new (class { } /** - * 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. @@ -575,9 +952,9 @@ var SelectTranslationsPanel = new (class { /** * Retrieves the current phase of the translation state. * - * @returns {SelectTranslationsPanelState} + * @returns {string} */ - #phase() { + phase() { return this.#translationState.phase; } @@ -585,14 +962,14 @@ var SelectTranslationsPanel = new (class { * @returns {boolean} True if the panel is open, otherwise false. */ #isOpen() { - return this.#phase() !== "closed"; + return this.phase() !== "closed"; } /** * @returns {boolean} True if the panel is closed, otherwise false. */ #isClosed() { - return this.#phase() === "closed"; + return this.phase() === "closed"; } /** @@ -604,17 +981,16 @@ var SelectTranslationsPanel = new (class { * @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 "init-failure": + case "translation-failure": case "translatable": - case "translated": { - textArea.classList.remove("translating"); + case "translating": + case "translated": + case "unsupported": { + // Phase is valid, continue on. break; } default: { @@ -622,7 +998,7 @@ var SelectTranslationsPanel = new (class { } } - const previousPhase = this.#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 }; @@ -637,19 +1013,26 @@ var SelectTranslationsPanel = new (class { this.#translationState = { phase }; } - if (previousPhase === this.#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; + const { fromLanguage, toLanguage, detectedLanguage } = + this.#translationState; + const sourceLanguage = fromLanguage ? fromLanguage : detectedLanguage; this.console?.debug( - `SelectTranslationsPanel (${fromLanguage ? fromLanguage : "??"}-${ + `SelectTranslationsPanel (${sourceLanguage ? sourceLanguage : "??"}-${ toLanguage ? toLanguage : "??" }) state change (${previousPhase} => ${phase})` ); this.#updatePanelUIFromState(); + document.dispatchEvent( + new CustomEvent("SelectTranslationsPanelStateChanged", { + detail: { phase }, + }) + ); } /** @@ -665,7 +1048,7 @@ var SelectTranslationsPanel = new (class { * @throws {Error} If the current state is not "translatable". */ #changeStateToTranslating() { - const phase = this.#phase(); + const phase = this.phase(); if (phase !== "translatable") { throw new Error(`Invalid state change (${phase} => translating)`); } @@ -678,7 +1061,7 @@ var SelectTranslationsPanel = new (class { * @throws {Error} If the current state is not "translating". */ #changeStateToTranslated(translatedText) { - const phase = this.#phase(); + const phase = this.phase(); if (phase !== "translating") { throw new Error(`Invalid state change (${phase} => translated)`); } @@ -688,45 +1071,176 @@ var SelectTranslationsPanel = new (class { } /** - * Transitions the phase of the state based on the given language pair. + * Changes the phase to "init-failure". + */ + #changeStateToInitFailure( + event, + screenX, + screenY, + sourceText, + langPairPromise + ) { + this.#changeStateTo("init-failure", /* retainEntries */ true, { + event, + screenX, + screenY, + sourceText, + langPairPromise, + }); + } + + /** + * Changes the phase from "translating" to "translation-failure". + */ + #changeStateToTranslationFailure() { + const phase = this.phase(); + if (phase !== "translating") { + this.console?.error( + `Invalid state change (${phase} => translation-failure)` + ); + } + this.#changeStateTo("translation-failure", /* retainEntries */ true); + } + + /** + * Transitions the phase to "translatable" if the proper conditions are met, + * otherwise retains the same phase as before. * * @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) { + #maybeChangeStateToTranslatable(fromLanguage, toLanguage) { const { - phase: previousPhase, fromLanguage: previousFromLanguage, toLanguage: previousToLanguage, } = this.#translationState; - let nextPhase = "translatable"; + const langSelectionChanged = () => + previousFromLanguage !== fromLanguage || + previousToLanguage !== toLanguage; + + const shouldTranslateEvenIfLangSelectionHasNotChanged = () => { + const phase = this.phase(); + return ( + // The panel has just opened, and this is the initial translation. + phase === "idle" || + // The previous translation failed and we are about to try again. + phase === "translation-failure" + ); + }; 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 + // A valid from-language is actively selected. + fromLanguage && + // A valid to-language is actively selected. + toLanguage && + // The language selection has changed, requiring a new translation. + (langSelectionChanged() || + // We should try to translate even if the language selection has not changed. + shouldTranslateEvenIfLangSelectionHasNotChanged()) ) { - nextPhase = previousPhase; + this.#changeStateTo("translatable", /* retainEntries */ true, { + fromLanguage, + toLanguage, + }); } + } - this.#changeStateTo(nextPhase, /* retainEntries */ true, { - fromLanguage, - toLanguage, - }); + /** + * Handles changes to the copy button based on the current translation state. + * + * @param {string} phase - The current phase of the translation state. + */ + #handleCopyButtonChanges(phase) { + switch (phase) { + case "closed": + case "translation-failure": + case "translated": { + this.#uncheckCopyButton(); + break; + } + case "idle": + case "init-failure": + case "translatable": + case "translating": + case "unsupported": { + // Do nothing. + break; + } + default: { + throw new Error(`Invalid state change to '${phase}'`); + } + } + } - return nextPhase; + /** + * Handles changes to the text area's background image based on the current translation state. + * + * @param {string} phase - The current phase of the translation state. + */ + #handleTextAreaBackgroundChanges(phase) { + const { textArea } = this.elements; + switch (phase) { + case "translating": { + textArea.classList.add("translating"); + break; + } + case "closed": + case "idle": + case "init-failure": + case "translation-failure": + case "translatable": + case "translated": + case "unsupported": { + textArea.classList.remove("translating"); + break; + } + default: { + throw new Error(`Invalid state change to '${phase}'`); + } + } + } + + /** + * Handles changes to the primary UI components based on the current translation state. + * + * @param {string} phase - The current phase of the translation state. + */ + #handlePrimaryUIChanges(phase) { + switch (phase) { + case "closed": + case "idle": { + this.#displayIdlePlaceholder(); + break; + } + case "init-failure": { + this.#displayInitFailureMessage(); + break; + } + case "translation-failure": { + this.#displayTranslationFailureMessage(); + break; + } + case "translatable": { + // Do nothing. + break; + } + case "translating": { + this.#displayTranslatingPlaceholder(); + break; + } + case "translated": { + this.#displayTranslatedText(); + break; + } + case "unsupported": { + this.#displayUnsupportedLanguageMessage(); + break; + } + default: { + throw new Error(`Invalid state change to '${phase}'`); + } + } } /** @@ -745,9 +1259,7 @@ var SelectTranslationsPanel = new (class { // 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) + this.#isSelectedLangPair(fromLanguage, toLanguage) ); } @@ -755,6 +1267,8 @@ var SelectTranslationsPanel = new (class { * Displays the placeholder text for the translation state's "idle" phase. */ #displayIdlePlaceholder() { + this.#showMainContent(); + const { textArea } = SelectTranslationsPanel.elements; textArea.value = this.#idlePlaceholderText; this.#updateTextDirection(); @@ -766,6 +1280,8 @@ var SelectTranslationsPanel = new (class { * Displays the placeholder text for the translation state's "translating" phase. */ #displayTranslatingPlaceholder() { + this.#showMainContent(); + const { textArea } = SelectTranslationsPanel.elements; textArea.value = this.#translatingPlaceholderText; this.#updateTextDirection(); @@ -777,6 +1293,8 @@ var SelectTranslationsPanel = new (class { * Displays the translated text for the translation state's "translated" phase. */ #displayTranslatedText() { + this.#showMainContent(); + const { toLanguage } = this.#getSelectedLanguagePair(); const { textArea } = SelectTranslationsPanel.elements; textArea.value = this.getTranslatedText(); @@ -786,38 +1304,238 @@ var SelectTranslationsPanel = new (class { } /** + * Sets attributes on panel elements that are specifically relevant + * to the SelectTranslationsPanel's state. + * + * @param {object} options - Options of which attributes to set. + * @param {Record<string, Element[]>} options.makeHidden - Make these elements hidden. + * @param {Record<string, Element[]>} options.makeVisible - Make these elements visible. + */ + #setPanelElementAttributes({ makeHidden = [], makeVisible = [] }) { + for (const element of makeHidden) { + element.hidden = true; + } + for (const element of makeVisible) { + element.hidden = false; + } + } + + /** * 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 { + copyButton, + textArea, + translateButton, + translateFullPageButton, + tryAnotherSourceMenuList, + } = this.elements; const invalidLangPairSelected = !fromLanguage || !toLanguage; - const isTranslating = this.#phase() === "translating"; + const isTranslating = this.phase() === "translating"; textArea.disabled = invalidLangPairSelected; - translateFullPageButton.disabled = invalidLangPairSelected; copyButton.disabled = invalidLangPairSelected || isTranslating; + translateButton.disabled = !tryAnotherSourceMenuList.value; + translateFullPageButton.disabled = + invalidLangPairSelected || + fromLanguage === toLanguage || + this.#isFullPageTranslationsRestrictedForPage; } /** * 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; + const phase = this.phase(); + this.#handlePrimaryUIChanges(phase); + this.#handleCopyButtonChanges(phase); + this.#handleTextAreaBackgroundChanges(phase); + } + + /** + * Shows the panel's main-content group of elements. + */ + #showMainContent() { + const { + cancelButton, + copyButton, + doneButtonPrimary, + doneButtonSecondary, + initFailureContent, + mainContent, + unsupportedLanguageContent, + textArea, + translateButton, + translateFullPageButton, + translationFailureMessageBar, + tryAgainButton, + } = this.elements; + this.#setPanelElementAttributes({ + makeHidden: [ + cancelButton, + doneButtonSecondary, + initFailureContent, + translateButton, + translationFailureMessageBar, + tryAgainButton, + unsupportedLanguageContent, + ...(this.#isFullPageTranslationsRestrictedForPage + ? [translateFullPageButton] + : []), + ], + makeVisible: [ + mainContent, + copyButton, + doneButtonPrimary, + textArea, + ...(this.#isFullPageTranslationsRestrictedForPage + ? [] + : [translateFullPageButton]), + ], + }); + } + + /** + * Shows the panel's unsupported-language group of elements. + */ + #showUnsupportedLanguageContent() { + const { + cancelButton, + copyButton, + doneButtonPrimary, + doneButtonSecondary, + initFailureContent, + mainContent, + unsupportedLanguageContent, + translateButton, + translateFullPageButton, + tryAgainButton, + } = this.elements; + this.#setPanelElementAttributes({ + makeHidden: [ + cancelButton, + doneButtonPrimary, + copyButton, + initFailureContent, + mainContent, + translateFullPageButton, + tryAgainButton, + ], + makeVisible: [ + doneButtonSecondary, + translateButton, + unsupportedLanguageContent, + ], + }); + } + + /** + * Displays the panel content for when the language dropdowns fail to populate. + */ + #displayInitFailureMessage() { + const { + cancelButton, + copyButton, + doneButtonPrimary, + doneButtonSecondary, + initFailureContent, + mainContent, + unsupportedLanguageContent, + translateButton, + translateFullPageButton, + tryAgainButton, + } = this.elements; + this.#setPanelElementAttributes({ + makeHidden: [ + doneButtonPrimary, + doneButtonSecondary, + copyButton, + mainContent, + translateButton, + translateFullPageButton, + unsupportedLanguageContent, + ], + makeVisible: [initFailureContent, cancelButton, tryAgainButton], + }); + tryAgainButton.focus({ focusVisible: true }); + } + + /** + * Displays the panel content for when a translation fails to complete. + */ + #displayTranslationFailureMessage() { + const { + cancelButton, + copyButton, + doneButtonPrimary, + doneButtonSecondary, + initFailureContent, + mainContent, + textArea, + translateButton, + translateFullPageButton, + translationFailureMessageBar, + tryAgainButton, + unsupportedLanguageContent, + } = this.elements; + this.#setPanelElementAttributes({ + makeHidden: [ + doneButtonPrimary, + doneButtonSecondary, + copyButton, + initFailureContent, + translateButton, + translateFullPageButton, + textArea, + unsupportedLanguageContent, + ], + makeVisible: [ + cancelButton, + mainContent, + translationFailureMessageBar, + tryAgainButton, + ], + }); + tryAgainButton.focus({ focusVisible: true }); + } + + /** + * Displays the panel's unsupported language message bar, showing + * the panel's unsupported-language elements. + */ + #displayUnsupportedLanguageMessage() { + const { detectedLanguage } = this.#translationState; + const { unsupportedLanguageMessageBar, tryAnotherSourceMenuList } = + this.elements; + const displayNames = new Services.intl.DisplayNames(undefined, { + type: "language", + }); + try { + const language = displayNames.of(detectedLanguage); + if (language) { + document.l10n.setAttributes( + unsupportedLanguageMessageBar, + "select-translations-panel-unsupported-language-message-known", + { language } + ); + } else { + // Will be immediately caught. + throw new Error(); } + } catch { + // Either displayNames.of() threw, or we threw due to no display name found. + // In either case, localize the message for an unknown language. + document.l10n.setAttributes( + unsupportedLanguageMessageBar, + "select-translations-panel-unsupported-language-message-unknown" + ); } + this.#updateConditionalUIEnabledState(); + this.#showUnsupportedLanguageContent(); + this.#maybeFocusMenuList(tryAnotherSourceMenuList); } /** @@ -868,25 +1586,16 @@ var SelectTranslationsPanel = new (class { * * @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; - } - + async #createTranslator(fromLanguage, toLanguage) { 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; + const translator = await Translator.create(fromLanguage, toLanguage, { + allowSameLanguage: true, + requestTranslationsPort: this.#requestTranslationsPort, + }); + return translator; } /** @@ -897,14 +1606,16 @@ var SelectTranslationsPanel = new (class { if (this.#isClosed()) { return; } + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); - const nextState = this.#changeStateByLanguagePair(fromLanguage, toLanguage); - if (nextState !== "translatable") { + this.#maybeChangeStateToTranslatable(fromLanguage, toLanguage); + + if (this.phase() !== "translatable") { return; } const translationId = ++this.#translationId; - this.#getOrCreateTranslator(fromLanguage, toLanguage) + this.#createTranslator(fromLanguage, toLanguage) .then(translator => { if ( this.#shouldContinueTranslation( @@ -928,13 +1639,12 @@ var SelectTranslationsPanel = new (class { ) ) { this.#changeStateToTranslated(translatedText); - } else if (this.#isOpen()) { - this.#changeStateTo("idle", /* retainEntires */ false, { - sourceText: this.getSourceText(), - }); } }) - .catch(error => this.console?.error(error)); + .catch(error => { + this.console?.error(error); + this.#changeStateToTranslationFailure(); + }); } /** @@ -952,11 +1662,10 @@ var SelectTranslationsPanel = new (class { for (const eventType of eventTypes) { let callback; switch (eventType) { - case "blur": + case "focus": case "popuphidden": { callback = () => { this.#maybeRequestTranslation(); - this.#removeTranslationListeners(target); }; break; } @@ -965,7 +1674,6 @@ var SelectTranslationsPanel = new (class { if (event.key === "Enter") { this.#maybeRequestTranslation(); } - this.#removeTranslationListeners(target); }; break; } @@ -975,21 +1683,39 @@ var SelectTranslationsPanel = new (class { ); } } - target.addEventListener(eventType, callback, { once: true }); + target.addEventListener(eventType, callback); target.translationListenerCallbacks.push({ eventType, callback }); } } } /** + * Removes all translation event listeners from any panel elements that would have one. + */ + #removeActiveTranslationListeners() { + const { fromMenuList, fromMenuPopup, textArea, toMenuList, toMenuPopup } = + SelectTranslationsPanel.elements; + this.#removeTranslationListenersFrom(fromMenuList); + this.#removeTranslationListenersFrom(fromMenuPopup); + this.#removeTranslationListenersFrom(textArea); + this.#removeTranslationListenersFrom(toMenuList); + this.#removeTranslationListenersFrom(toMenuPopup); + } + + /** * Removes all translation event listeners from the target element. * * @param {Element} target - The element from which event listeners are to be removed. */ - #removeTranslationListeners(target) { + #removeTranslationListenersFrom(target) { + if (!target.translationListenerCallbacks) { + return; + } + for (const { eventType, callback } of target.translationListenerCallbacks) { target.removeEventListener(eventType, callback); } + target.translationListenerCallbacks = []; } })(); diff --git a/browser/components/translations/tests/browser/browser.toml b/browser/components/translations/tests/browser/browser.toml index 472ae28866..78ffd04c09 100644 --- a/browser/components/translations/tests/browser/browser.toml +++ b/browser/components/translations/tests/browser/browser.toml @@ -105,6 +105,8 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227 ["browser_translations_full_page_telemetry_translation_request.js"] +["browser_translations_select_context_menu_engine_unsupported.js"] + ["browser_translations_select_context_menu_feature_disabled.js"] ["browser_translations_select_context_menu_with_full_page_translations_active.js"] @@ -115,11 +117,19 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227 ["browser_translations_select_context_menu_with_text_selected.js"] +["browser_translations_select_panel_close_on_new_tab.js"] + +["browser_translations_select_panel_copy_button.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_init_failure.js"] + +["browser_translations_select_panel_pdf.js"] + +["browser_translations_select_panel_reader_mode.js"] ["browser_translations_select_panel_retranslate_on_change_language_directly.js"] @@ -133,6 +143,10 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227 ["browser_translations_select_panel_select_same_from_and_to_languages_from_dropdown_menu.js"] +["browser_translations_select_panel_settings_menu.js"] + +["browser_translations_select_panel_translate_full_page_button.js"] + ["browser_translations_select_panel_translate_on_change_language_directly.js"] ["browser_translations_select_panel_translate_on_change_language_from_dropdown_menu.js"] @@ -142,3 +156,11 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227 ["browser_translations_select_panel_translate_on_change_language_multiple_times_from_dropdown_menu.js"] ["browser_translations_select_panel_translate_on_open.js"] + +["browser_translations_select_panel_translation_failure_after_unsupported_language.js"] + +["browser_translations_select_panel_translation_failure_on_open.js"] + +["browser_translations_select_panel_translation_failure_on_retranslate.js"] + +["browser_translations_select_panel_unsupported_language.js"] diff --git a/browser/components/translations/tests/browser/browser_translations_about_preferences_manage_downloaded_languages.js b/browser/components/translations/tests/browser/browser_translations_about_preferences_manage_downloaded_languages.js index 383f2094a7..6d5b10f26c 100644 --- a/browser/components/translations/tests/browser/browser_translations_about_preferences_manage_downloaded_languages.js +++ b/browser/components/translations/tests/browser/browser_translations_about_preferences_manage_downloaded_languages.js @@ -36,7 +36,7 @@ add_task(async function test_about_preferences_manage_languages() { is( downloadAllLabel.getAttribute("data-l10n-id"), - "translations-manage-install-description", + "translations-manage-download-description", "The first row is all of the languages." ); is(frenchLabel.textContent, "French", "There is a French row."); @@ -178,7 +178,7 @@ add_task(async function test_about_preferences_download_reject() { click(frenchDownload, "Downloading French"); is( - maybeGetByL10nId("translations-manage-error-install", document), + maybeGetByL10nId("translations-manage-error-download", document), null, "No error messages are present." ); @@ -200,13 +200,13 @@ add_task(async function test_about_preferences_download_reject() { } await waitForCondition( - () => maybeGetByL10nId("translations-manage-error-install", document), + () => maybeGetByL10nId("translations-manage-error-download", document), "The error message is now visible." ); click(frenchDownload, "Attempting to download French again", document); is( - maybeGetByL10nId("translations-manage-error-install", document), + maybeGetByL10nId("translations-manage-error-download", document), null, "The error message is hidden again." ); 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 f618b27814..39495a823c 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 @@ -161,7 +161,7 @@ async function testLanguageList(translateSection, menuList) { let langButton = languagelist.children[0].querySelector("moz-button"); let clickButton = BrowserTestUtils.waitForEvent(langButton, "click"); - langButton.dispatchEvent(new Event("click")); + langButton.click(); await clickButton; if (i < langNum - 1) { @@ -242,9 +242,7 @@ add_task(async function test_translations_settings_download_languages() { langList.children[i].querySelector("moz-button"), "click" ); - langList.children[i] - .querySelector("moz-button") - .dispatchEvent(new Event("click")); + langList.children[i].querySelector("moz-button").click(); await clickButton; is( @@ -259,9 +257,7 @@ add_task(async function test_translations_settings_download_languages() { langList.children[i].querySelector("moz-button"), "click" ); - langList.children[i] - .querySelector("moz-button") - .dispatchEvent(new Event("click")); + langList.children[i].querySelector("moz-button").click(); await clickButton; is( diff --git a/browser/components/translations/tests/browser/browser_translations_select_context_menu_engine_unsupported.js b/browser/components/translations/tests/browser/browser_translations_select_context_menu_engine_unsupported.js new file mode 100644 index 0000000000..b5fe9de0ef --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_context_menu_engine_unsupported.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test checks the availability of the translate-selection menu item in the context menu, + * ensuring it is not visible when the hardware does not support Translations. In this case + * we simulate this scenario by setting "browser.translations.simulateUnsupportedEngine" to true. + */ +add_task( + async function test_translate_selection_menuitem_is_unavailable_when_engine_is_unsupported() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [ + ["browser.translations.enable", true], + ["browser.translations.select.enable", true], + ["browser.translations.simulateUnsupportedEngine", true], + ], + }); + + await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem( + runInPage, + { + selectSpanishSentence: true, + openAtSpanishSentence: true, + expectMenuItemVisible: false, + }, + "The translate-selection context menu item should be unavailable the translations engine is unsupported." + ); + + await cleanup(); + } +); 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 83e836489f..cb0f3601d9 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 @@ -41,8 +41,8 @@ add_task( /** * This test case verifies the functionality of the translate-selection context menu item * when a hyperlink is right-clicked, and the link text is in the top preferred language. - * The menu item should offer to translate the link text without specifying a target language, - * since it is already in the preferred language for the user. + * The menu item should still offer to translate the link text to the top preferred language, + * since the Select Translations Panel should pass through the text for same-language translation. */ add_task( async function test_translate_selection_menuitem_translate_link_text_in_preferred_language() { @@ -63,10 +63,10 @@ add_task( selectSpanishSentence: false, openAtEnglishHyperlink: true, expectMenuItemVisible: true, - expectedTargetLanguage: null, + expectedTargetLanguage: "en", }, "The translate-selection context menu item should be localized to translate the link text" + - "without a target language." + "to the target language." ); await cleanup(); 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 6b44f2ca1f..562eef3efb 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 @@ -43,8 +43,8 @@ add_task( /** * This test case verifies the functionality of the translate-selection context menu item * when the selected text is detected to be in the user's preferred language. The menu item - * should not be localized to display a target language when the selected text matches the - * user's top preferred language. + * still be localized to the user's preferred language as a target, since the Select Translations + * Panel allows passing through the text for same-language translation. */ add_task( async function test_translate_selection_menuitem_when_selected_text_is_preferred_language() { @@ -65,9 +65,9 @@ add_task( selectEnglishSentence: true, openAtEnglishSentence: true, expectMenuItemVisible: true, - expectedTargetLanguage: null, + expectedTargetLanguage: "en", }, - "The translate-selection context menu item should not display a target language " + + "The translate-selection context menu item should still display a target language " + "when the selected text is in the preferred language." ); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_close_on_new_tab.js b/browser/components/translations/tests/browser/browser_translations_select_panel_close_on_new_tab.js new file mode 100644 index 0000000000..86e563b157 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_close_on_new_tab.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the scenario where the SelectTranslationsPanel is open + * and the user opens a new tab while the panel is still open. The panel should + * close appropriately, as the content relevant to the selection is no longer + * in the active tab. + */ +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, + }); + + let tab; + + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popuphidden", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SPANISH_PAGE_URL, + true // waitForLoad + ); + } + ); + + BrowserTestUtils.removeTab(tab); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_copy_button.js b/browser/components/translations/tests/browser/browser_translations_select_panel_copy_button.js new file mode 100644 index 0000000000..29eafb980d --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_copy_button.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests functionality of the SelectTranslationsPanel copy button + * when retranslating by closing the panel and re-opening the panel to new links + * or selections of text. + */ +add_task(async function test_select_translations_panel_copy_button_on_reopen() { + 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.clickCopyButton(); + await SelectTranslationsTestUtils.clickDoneButton(); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectEnglishSection: true, + openAtEnglishSection: true, + expectedFromLanguage: "en", + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickCopyButton(); + await SelectTranslationsTestUtils.clickDoneButton(); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSection: true, + openAtFrenchSection: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickCopyButton(); + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); +}); + +/** + * This test case tests functionality of the SelectTranslationsPanel copy button + * when retranslating by changing the from-language and to-language values for + * the same selection of source text. + */ +add_task( + async function test_select_translations_panel_copy_button_on_retranslate() { + 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.clickCopyButton(); + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickCopyButton(); + await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { + openDropdownMenu: false, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickCopyButton(); + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_init_failure.js b/browser/components/translations/tests/browser/browser_translations_select_panel_init_failure.js new file mode 100644 index 0000000000..9e17b0705f --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_init_failure.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the scenario of clicking the cancel button to close + * the SelectTranslationsPanel after the language lists fail to initialize upon + * opening the panel, and the proper error message is displayed. + */ +add_task(async function test_select_translations_panel_init_failure_cancel() { + const { cleanup, runInPage } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + TranslationsPanelShared.simulateLangListError(); + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewInitFailure, + }); + + await SelectTranslationsTestUtils.clickCancelButton(); + + await cleanup(); +}); + +/** + * This test case verifies the scenario of opening the SelectTranslationsPanel to a valid + * language pair, but having the language lists fail to initialize, then clicking the try-again + * button multiple times until both initialization and translation succeed. + */ +add_task( + async function test_select_translations_panel_init_failure_try_again_into_translation() { + const { cleanup, runInPage, resolveDownloads, rejectDownloads } = + await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + TranslationsPanelShared.simulateLangListError(); + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectFrenchSentence: true, + openAtFrenchSentence: true, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewInitFailure, + }); + + TranslationsPanelShared.simulateLangListError(); + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popupshown", + SelectTranslationsTestUtils.clickTryAgainButton, + SelectTranslationsTestUtils.assertPanelViewInitFailure + ); + + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popupshown", + async () => + SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: rejectDownloads, + }), + SelectTranslationsTestUtils.assertPanelViewTranslationFailure + ); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: resolveDownloads, + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case verifies the scenario of opening the SelectTranslationsPanel to an unsupported + * language, but having the language lists fail to initialize, then clicking the try-again + * button multiple times until the unsupported-language view is shown. + */ +add_task( + async function test_select_translations_panel_init_failure_try_again_into_unsupported() { + 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]], + }); + + TranslationsPanelShared.simulateLangListError(); + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSection: true, + openAtSpanishSection: true, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewInitFailure, + }); + + TranslationsPanelShared.simulateLangListError(); + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popupshown", + SelectTranslationsTestUtils.clickTryAgainButton, + SelectTranslationsTestUtils.assertPanelViewInitFailure + ); + + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popupshown", + SelectTranslationsTestUtils.clickTryAgainButton, + SelectTranslationsTestUtils.assertPanelViewUnsupportedLanguage + ); + + 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 deleted file mode 100644 index d5a1096e70..0000000000 --- a/browser/components/translations/tests/browser/browser_translations_select_panel_open_to_idle_state.js +++ /dev/null @@ -1,61 +0,0 @@ -/* 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_pdf.js b/browser/components/translations/tests/browser/browser_translations_select_panel_pdf.js new file mode 100644 index 0000000000..fd675e9cea --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_pdf.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies that the Select Translations Panel functionality + * is available and works within PDF files. + */ +add_task(async function test_the_select_translations_panel_in_pdf_files() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: PDF_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectPdfSpan: true, + openAtPdfSpan: true, + expectedFromLanguage: "en", + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: true, + pivotTranslation: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["fr"], { + openDropdownMenu: false, + pivotTranslation: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); +}); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_reader_mode.js b/browser/components/translations/tests/browser/browser_translations_select_panel_reader_mode.js new file mode 100644 index 0000000000..5f05b1a878 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_reader_mode.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies that the Select Translations Panel functionality + * is available and works within reader mode. + */ +add_task(async function test_the_select_translations_panel_in_reader_mode() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await toggleReaderMode(); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectH1: true, + openAtH1: true, + expectedFromLanguage: "en", + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: true, + pivotTranslation: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["fr"], { + openDropdownMenu: false, + pivotTranslation: true, + downloadHandler: resolveDownloads, + 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 index 95feac6708..673faee796 100644 --- 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 @@ -10,29 +10,24 @@ */ add_task( async function test_select_translations_panel_select_same_from_language_directly() { - const { cleanup, runInPage } = await loadTestPage({ + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ page: SELECT_TEST_PAGE_URL, - languagePairs: [ - // Do not include Spanish. - { fromLang: "fr", toLang: "en" }, - { fromLang: "en", toLang: "fr" }, - ], + languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); await SelectTranslationsTestUtils.openPanel(runInPage, { selectSpanishSection: true, openAtSpanishSection: true, - expectedFromLanguage: null, + expectedFromLanguage: "es", expectedToLanguage: "en", - onOpenPanel: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedFromLanguage(["en"], { openDropdownMenu: false, - onChangeLanguage: - SelectTranslationsTestUtils.assertPanelViewNoFromToSelected, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await cleanup(); @@ -41,29 +36,29 @@ add_task( /** * 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. + * that is currently selected in the from-language, creating a passthrough translation + * of the source text directly into the text area. */ add_task( async function test_select_translations_panel_select_same_to_language_directly() { - const { cleanup, runInPage } = await loadTestPage({ + 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, + selectFrenchSentence: true, + openAtFrenchSentence: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); - await SelectTranslationsTestUtils.changeSelectedToLanguage(["en"], { + await SelectTranslationsTestUtils.changeSelectedToLanguage(["fr"], { openDropdownMenu: false, - onChangeLanguage: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, }); 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 index 5c27be411f..eea7a76bf2 100644 --- 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 @@ -10,29 +10,24 @@ */ add_task( async function test_select_translations_panel_select_same_from_language_via_popup() { - const { cleanup, runInPage } = await loadTestPage({ + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ page: SELECT_TEST_PAGE_URL, - languagePairs: [ - // Do not include Spanish. - { fromLang: "fr", toLang: "en" }, - { fromLang: "en", toLang: "fr" }, - ], + languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); await SelectTranslationsTestUtils.openPanel(runInPage, { selectSpanishSection: true, openAtSpanishSection: true, - expectedFromLanguage: null, + expectedFromLanguage: "es", expectedToLanguage: "en", - onOpenPanel: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedFromLanguage(["en"], { openDropdownMenu: true, - onChangeLanguage: - SelectTranslationsTestUtils.assertPanelViewNoFromToSelected, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await cleanup(); @@ -42,28 +37,28 @@ add_task( /** * 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. + * creating a passthrough translation of the source text directly into the text area. */ add_task( async function test_select_translations_panel_select_same_to_language_via_popup() { - const { cleanup, runInPage } = await loadTestPage({ + 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, + selectFrenchSentence: true, + openAtFrenchSentence: true, + expectedFromLanguage: "fr", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); - await SelectTranslationsTestUtils.changeSelectedToLanguage(["en"], { + await SelectTranslationsTestUtils.changeSelectedToLanguage(["fr"], { openDropdownMenu: true, - onChangeLanguage: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await cleanup(); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_settings_menu.js b/browser/components/translations/tests/browser/browser_translations_select_panel_settings_menu.js new file mode 100644 index 0000000000..b6263325d5 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_settings_menu.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the scenario of clicking the settings menu item + * that leads to the translations section of the about:preferences settings + * page in Firefox. + */ +add_task(async function test_select_translations_panel_open_settings_page() { + 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.openPanelSettingsMenu(); + SelectTranslationsTestUtils.clickTranslationsSettingsPageMenuItem(); + + await waitForCondition( + () => gBrowser.currentURI.spec === "about:preferences#general", + "Waiting for about:preferences to be opened." + ); + + info("Remove the about:preferences tab"); + gBrowser.removeCurrentTab(); + + await cleanup(); +}); + +/** + * This test case tests the scenario of opening the SelectTranslationsPanel + * settings menu from the unsupported-language panel state. + */ +add_task( + async function test_select_translations_panel_open_settings_menu_from_unsupported_language() { + 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, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewUnsupportedLanguage, + }); + + await SelectTranslationsTestUtils.openPanelSettingsMenu(); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translate_full_page_button.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_full_page_button.js new file mode 100644 index 0000000000..a2e9727798 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translate_full_page_button.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Simulates clicking the translate-full-page button with a from-language that + * matches the language of the given document. + */ +add_task( + async function test_select_translations_panel_translat_full_page_button_matching_doc_lang() { + 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.clickTranslateFullPageButton(); + + await FullPageTranslationsTestUtils.assertPageIsTranslated( + "es", + "en", + runInPage + ); + + await cleanup(); + } +); + +/** + * Simulates clicking the translate-full-page button after changing the from-language + * and to-language values to values that don't match the document language or the + * user's app locale, ensuring that the current selection is respected. + */ +add_task( + async function test_select_translations_panel_translat_full_page_button_matching_doc_lang() { + const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ + page: SELECT_TEST_PAGE_URL, + languagePairs: LANGUAGE_PAIRS, + prefs: [["browser.translations.select.enable", true]], + }); + + await SelectTranslationsTestUtils.openPanel(runInPage, { + selectSpanishSection: true, + openAtSpanishSection: true, + expectedFromLanguage: "es", + expectedToLanguage: "en", + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["fr"], { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["uk"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + pivotTranslation: true, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickTranslateFullPageButton(); + + await FullPageTranslationsTestUtils.assertPageIsTranslated( + "fr", + "uk", + runInPage + ); + + 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 index 64d067d1f4..7ea721fb0f 100644 --- 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 @@ -12,21 +12,17 @@ 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" }, - ], + languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); await SelectTranslationsTestUtils.openPanel(runInPage, { selectSpanishSection: true, openAtSpanishSection: true, - expectedFromLanguage: null, + expectedFromLanguage: "es", expectedToLanguage: "en", - onOpenPanel: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedFromLanguage(["fr"], { @@ -56,8 +52,8 @@ add_task( selectEnglishSection: true, openAtEnglishSection: true, expectedFromLanguage: "en", - expectedToLanguage: null, - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { 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 index 0cd205d721..c0ba02f6db 100644 --- 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 @@ -12,21 +12,17 @@ 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" }, - ], + languagePairs: LANGUAGE_PAIRS, prefs: [["browser.translations.select.enable", true]], }); await SelectTranslationsTestUtils.openPanel(runInPage, { selectSpanishSection: true, openAtSpanishSection: true, - expectedFromLanguage: null, + expectedFromLanguage: "es", expectedToLanguage: "en", - onOpenPanel: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedFromLanguage(["fr"], { @@ -56,8 +52,8 @@ add_task( selectEnglishSection: true, openAtEnglishSection: true, expectedFromLanguage: "en", - expectedToLanguage: null, - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { 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 index b3c02a96f6..cd60f73e6c 100644 --- 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 @@ -13,7 +13,8 @@ add_task( const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ page: SELECT_TEST_PAGE_URL, languagePairs: [ - // Do not include Spanish. + { fromLang: "es", toLang: "en" }, + { fromLang: "en", toLang: "es" }, { fromLang: "fa", toLang: "en" }, { fromLang: "en", toLang: "fa" }, { fromLang: "fi", toLang: "en" }, @@ -31,10 +32,10 @@ add_task( await SelectTranslationsTestUtils.openPanel(runInPage, { selectSpanishSentence: true, openAtSpanishSentence: true, - expectedFromLanguage: null, + expectedFromLanguage: "es", expectedToLanguage: "en", - onOpenPanel: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedFromLanguage( @@ -79,8 +80,8 @@ add_task( await SelectTranslationsTestUtils.openPanel(runInPage, { openAtEnglishHyperlink: true, expectedFromLanguage: "en", - expectedToLanguage: null, - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedToLanguage( 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 index 50c877cfbc..1b2044ef97 100644 --- 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 @@ -13,7 +13,8 @@ add_task( const { cleanup, runInPage, resolveDownloads } = await loadTestPage({ page: SELECT_TEST_PAGE_URL, languagePairs: [ - // Do not include Spanish. + { fromLang: "es", toLang: "en" }, + { fromLang: "en", toLang: "es" }, { fromLang: "fa", toLang: "en" }, { fromLang: "en", toLang: "fa" }, { fromLang: "fi", toLang: "en" }, @@ -31,10 +32,10 @@ add_task( await SelectTranslationsTestUtils.openPanel(runInPage, { selectSpanishSentence: true, openAtSpanishSentence: true, - expectedFromLanguage: null, + expectedFromLanguage: "es", expectedToLanguage: "en", - onOpenPanel: - SelectTranslationsTestUtils.assertPanelViewNoFromLangSelected, + downloadHandler: resolveDownloads, + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedFromLanguage( @@ -80,8 +81,8 @@ add_task( selectEnglishSentence: true, openAtEnglishSentence: true, expectedFromLanguage: "en", - expectedToLanguage: null, - onOpenPanel: SelectTranslationsTestUtils.assertPanelViewNoToLangSelected, + expectedToLanguage: "en", + onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated, }); await SelectTranslationsTestUtils.changeSelectedToLanguage( diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_after_unsupported_language.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_after_unsupported_language.js new file mode 100644 index 0000000000..79ce46b7dc --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_after_unsupported_language.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the scenario of encountering the translation failure message + * as a result of changing the source language from the unsupported-language state. + */ +add_task( + async function test_select_translations_panel_failure_after_unsupported_language() { + const { cleanup, runInPage, resolveDownloads, rejectDownloads } = + 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, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewUnsupportedLanguage, + }); + + await SelectTranslationsTestUtils.changeSelectedTryAnotherSourceLanguage( + "fr" + ); + + await SelectTranslationsTestUtils.clickTranslateButton({ + downloadHandler: rejectDownloads, + viewAssertion: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: rejectDownloads, + viewAssertion: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: resolveDownloads, + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_on_open.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_on_open.js new file mode 100644 index 0000000000..0fc133f269 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_on_open.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the scenario of opening the SelectTranslationsPanel to a translation + * attempt that fails, followed by closing the panel via the cancel button, and then re-attempting + * the translation by re-opening the panel and having it succeed. + */ +add_task( + async function test_select_translations_panel_translation_failure_on_open_then_cancel_and_reopen() { + const { cleanup, runInPage, rejectDownloads, 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: rejectDownloads, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickCancelButton(); + + 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 scenario of opening the SelectTranslationsPanel to a translation + * attempt that fails, followed by clicking the try-again button multiple times to retry the + * translation until it finally succeeds. + */ +add_task( + async function test_select_translations_panel_translation_failure_on_open_then_try_again() { + const { cleanup, runInPage, rejectDownloads, 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: rejectDownloads, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: rejectDownloads, + viewAssertion: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: rejectDownloads, + viewAssertion: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: resolveDownloads, + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_on_retranslate.js b/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_on_retranslate.js new file mode 100644 index 0000000000..d19439c6a4 --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_translation_failure_on_retranslate.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case tests the scenario of encountering the translation failure message + * as a result of changing the selected from-language, along with moving from the failure + * state to a successful translation also by changing the selected from-language. + */ +add_task( + async function test_select_translations_panel_translation_failure_on_change_from_language() { + const { cleanup, runInPage, rejectDownloads, 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, + downloadHandler: rejectDownloads, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["uk"], { + openDropdownMenu: true, + downloadHandler: rejectDownloads, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["es"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["uk"], { + openDropdownMenu: false, + downloadHandler: rejectDownloads, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: resolveDownloads, + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case tests the scenario of encountering the translation failure message + * as a result of changing the selected to-language, along with moving from the failure + * state to a successful translation also by changing the selected to-language. + */ +add_task( + async function test_select_translations_panel_translation_failure_on_change_to_language() { + const { cleanup, runInPage, rejectDownloads, 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, + downloadHandler: rejectDownloads, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["uk"], { + openDropdownMenu: true, + downloadHandler: rejectDownloads, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["es"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + pivotTranslation: true, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["uk"], { + openDropdownMenu: false, + downloadHandler: rejectDownloads, + onChangeLanguage: + SelectTranslationsTestUtils.assertPanelViewTranslationFailure, + }); + + await SelectTranslationsTestUtils.clickTryAgainButton({ + downloadHandler: resolveDownloads, + pivotTranslation: true, + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); diff --git a/browser/components/translations/tests/browser/browser_translations_select_panel_unsupported_language.js b/browser/components/translations/tests/browser/browser_translations_select_panel_unsupported_language.js new file mode 100644 index 0000000000..0898bd125b --- /dev/null +++ b/browser/components/translations/tests/browser/browser_translations_select_panel_unsupported_language.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test case verifies the behavior of opening the SelectTranslationsPanel to an unsupported language + * and then clicking the done button to close the panel. + */ +add_task( + async function test_select_translations_panel_unsupported_click_done_button() { + 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, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewUnsupportedLanguage, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of opening the SelectTranslationsPanel to an unsupported language + * then changing the source language to the same language as the app locale, triggering a same-language + * translation, then changing the from-language and to-language multiple times. + */ +add_task( + async function test_select_translations_panel_unsupported_then_to_same_language_translation() { + 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, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewUnsupportedLanguage, + }); + + await SelectTranslationsTestUtils.changeSelectedTryAnotherSourceLanguage( + "en" + ); + + await SelectTranslationsTestUtils.clickTranslateButton({ + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["uk", "fi"], { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["sl", "fr"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + pivotTranslation: true, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["fr"], { + openDropdownMenu: true, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.clickDoneButton(); + + await cleanup(); + } +); + +/** + * This test case verifies the behavior of opening the SelectTranslationsPanel to an unsupported language + * then changing the source language to a valid language, followed by changing the from-language and to-language + * multiple times. + */ +add_task( + async function test_select_translations_panel_unsupported_into_different_language_translation() { + 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, + onOpenPanel: + SelectTranslationsTestUtils.assertPanelViewUnsupportedLanguage, + }); + + await SelectTranslationsTestUtils.changeSelectedTryAnotherSourceLanguage( + "fr" + ); + + await SelectTranslationsTestUtils.clickTranslateButton({ + downloadHandler: resolveDownloads, + viewAssertion: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["uk", "fi"], { + openDropdownMenu: false, + downloadHandler: resolveDownloads, + pivotTranslation: true, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedToLanguage(["sl", "uk"], { + openDropdownMenu: true, + downloadHandler: resolveDownloads, + pivotTranslation: true, + onChangeLanguage: SelectTranslationsTestUtils.assertPanelViewTranslated, + }); + + await SelectTranslationsTestUtils.changeSelectedFromLanguage(["uk"], { + openDropdownMenu: false, + onChangeLanguage: 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 454de9146b..4bd5dc074f 100644 --- a/browser/components/translations/tests/browser/head.js +++ b/browser/components/translations/tests/browser/head.js @@ -297,6 +297,20 @@ class SharedTranslationsTestUtils { } /** + * Asserts that the given element has the expected L10nId. + * + * @param {Element} element - The element to assert against. + * @param {string} l10nId - The expected localization id. + */ + static _assertL10nId(element, l10nId) { + is( + element.getAttribute("data-l10n-id"), + l10nId, + `The element ${element.id} should have L10n Id ${l10nId}.` + ); + } + + /** * Asserts that the mainViewId of the panel matches the given string. * * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel @@ -369,6 +383,28 @@ class SharedTranslationsTestUtils { } /** + * Asserts that the given elements are focusable in order + * via the tab key, starting with the first element already + * focused and ending back on that same first element. + * + * @param {Element[]} elements - The focusable elements. + */ + static _assertTabIndexOrder(elements) { + const activeElementAtStart = document.activeElement; + + if (elements.length) { + elements[0].focus(); + elements.push(elements[0]); + } + for (const element of elements) { + SharedTranslationsTestUtils._assertHasFocus(element); + EventUtils.synthesizeKey("KEY_Tab"); + } + + activeElementAtStart.focus(); + } + + /** * Executes the provided callback before waiting for the event and then waits for the given event * to be fired for the element corresponding to the provided elementId. * @@ -696,11 +732,7 @@ class FullPageTranslationsTestUtils { */ static #assertPanelHeaderL10nId(l10nId) { const { header } = FullPageTranslationsPanel.elements; - is( - header.getAttribute("data-l10n-id"), - l10nId, - "The translations panel header should match the expected data-l10n-id" - ); + SharedTranslationsTestUtils._assertL10nId(header, l10nId); } /** @@ -710,11 +742,7 @@ class FullPageTranslationsTestUtils { */ static #assertPanelErrorL10nId(l10nId) { const { errorMessage } = FullPageTranslationsPanel.elements; - is( - errorMessage.getAttribute("data-l10n-id"), - l10nId, - "The translations panel error message should match the expected data-l10n-id" - ); + SharedTranslationsTestUtils._assertL10nId(errorMessage, l10nId); } /** @@ -1111,7 +1139,7 @@ class FullPageTranslationsTestUtils { static async #clickSettingsMenuItemByL10nId(l10nId) { info(`Toggling the "${l10nId}" settings menu item.`); click(getByL10nId(l10nId), `Clicking the "${l10nId}" settings menu item.`); - await closeSettingsMenuIfOpen(); + await closeFullPagePanelSettingsMenuIfOpen(); } /** @@ -1363,10 +1391,21 @@ 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. * - * 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. + * + * The following options will work on all test pages that have an <h1> element. + * + * @param {boolean} options.selectH1 - Selects the first H1 element of the page. + * @param {boolean} options.openAtH1 - Opens the context menu at the first H1 element of the page. + * + * The following options will work only in the PDF_TEST_PAGE_URL. + * + * @param {boolean} options.selectPdfSpan - Selects the first span of text on the first page of a pdf. + * @param {boolean} options.openAtPdfSpan - Opens the context menu at the first span of text on the first page of a pdf. + * + * 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. @@ -1390,12 +1429,16 @@ class SelectTranslationsTestUtils { { expectMenuItemVisible, expectedTargetLanguage, + selectH1, + selectPdfSpan, selectFrenchSection, selectEnglishSection, selectSpanishSection, selectFrenchSentence, selectEnglishSentence, selectSpanishSentence, + openAtH1, + openAtPdfSpan, openAtFrenchSection, openAtEnglishSection, openAtSpanishSection, @@ -1419,12 +1462,16 @@ class SelectTranslationsTestUtils { await SelectTranslationsTestUtils.openContextMenu(runInPage, { expectMenuItemVisible, expectedTargetLanguage, + selectH1, + selectPdfSpan, selectFrenchSection, selectEnglishSection, selectSpanishSection, selectFrenchSentence, selectEnglishSentence, selectSpanishSentence, + openAtH1, + openAtPdfSpan, openAtFrenchSection, openAtEnglishSection, openAtSpanishSection, @@ -1501,21 +1548,6 @@ 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. * @@ -1526,7 +1558,31 @@ class SelectTranslationsTestUtils { SharedTranslationsTestUtils._assertPanelElementVisibility( SelectTranslationsPanel.elements, { - ...SelectTranslationsTestUtils.#alwaysPresentElements, + betaIcon: false, + cancelButton: false, + copyButton: false, + doneButtonPrimary: false, + doneButtonSecondary: false, + fromLabel: false, + fromMenuList: false, + fromMenuPopup: false, + header: false, + initFailureContent: false, + initFailureMessageBar: false, + mainContent: false, + settingsButton: false, + textArea: false, + toLabel: false, + toMenuList: false, + toMenuPopup: false, + translateButton: false, + translateFullPageButton: false, + translationFailureMessageBar: false, + tryAgainButton: false, + tryAnotherSourceMenuList: false, + tryAnotherSourceMenuPopup: false, + unsupportedLanguageContent: false, + unsupportedLanguageMessageBar: false, // Overwrite any of the above defaults with the passed in expectations. ...expectations, } @@ -1534,26 +1590,172 @@ class SelectTranslationsTestUtils { } /** + * Waits for the panel's translation state to reach the given phase, + * if it is not currently in that phase already. + * + * @param {string} phase - The phase of the panel's translation state to wait for. + */ + static async waitForPanelState(phase) { + const currentPhase = SelectTranslationsPanel.phase(); + if (currentPhase !== phase) { + info( + `Waiting for SelectTranslationsPanel to change state from "${currentPhase}" to "${phase}"` + ); + await BrowserTestUtils.waitForEvent( + document, + "SelectTranslationsPanelStateChanged", + event => event.detail.phase === phase + ); + } + } + + /** * Asserts that the SelectTranslationsPanel UI matches the expected * state when the panel has completed its translation. */ - static assertPanelViewTranslated() { - const { textArea } = SelectTranslationsPanel.elements; + static async assertPanelViewTranslated() { + const { + copyButton, + doneButtonPrimary, + fromMenuList, + settingsButton, + textArea, + toMenuList, + translateFullPageButton, + } = SelectTranslationsPanel.elements; + const sameLanguageSelected = fromMenuList.value === toMenuList.value; + await SelectTranslationsTestUtils.waitForPanelState("translated"); ok( !textArea.classList.contains("translating"), "The textarea should not have the translating class." ); + const isFullPageTranslationsRestrictedForPage = + TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser); SelectTranslationsTestUtils.#assertPanelElementVisibility({ - ...SelectTranslationsTestUtils.#alwaysPresentElements, + betaIcon: true, + copyButton: true, + doneButtonPrimary: true, + fromLabel: true, + fromMenuList: true, + header: true, + mainContent: true, + settingsButton: true, + textArea: true, + toLabel: true, + toMenuList: true, + translateFullPageButton: !isFullPageTranslationsRestrictedForPage, }); SelectTranslationsTestUtils.#assertConditionalUIEnabled({ - textArea: true, copyButton: true, - translateFullPageButton: true, + doneButtonPrimary: true, + textArea: true, + translateFullPageButton: + !sameLanguageSelected && !isFullPageTranslationsRestrictedForPage, }); + + await waitForCondition( + () => + !copyButton.classList.contains("copied") && + copyButton.getAttribute("data-l10n-id") === + "select-translations-panel-copy-button", + "Waiting for copy button to match the not-copied state." + ); + SelectTranslationsTestUtils.#assertPanelHasTranslatedText(); SelectTranslationsTestUtils.#assertPanelTextAreaHeight(); - SelectTranslationsTestUtils.#assertPanelTextAreaOverflow(); + await SelectTranslationsTestUtils.#assertPanelTextAreaOverflow(); + + let footerButtons; + if (sameLanguageSelected || isFullPageTranslationsRestrictedForPage) { + footerButtons = [copyButton, doneButtonPrimary]; + } else { + footerButtons = + AppConstants.platform === "win" + ? [copyButton, doneButtonPrimary, translateFullPageButton] + : [copyButton, translateFullPageButton, doneButtonPrimary]; + } + + SharedTranslationsTestUtils._assertTabIndexOrder([ + settingsButton, + fromMenuList, + toMenuList, + textArea, + ...footerButtons, + ]); + } + + /** + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when the language lists fail to initialize upon opening the panel. + */ + static async assertPanelViewInitFailure() { + const { cancelButton, settingsButton, tryAgainButton } = + SelectTranslationsPanel.elements; + await SelectTranslationsTestUtils.waitForPanelState("init-failure"); + SelectTranslationsTestUtils.#assertPanelElementVisibility({ + header: true, + betaIcon: true, + cancelButton: true, + initFailureContent: true, + initFailureMessageBar: true, + settingsButton: true, + tryAgainButton: true, + }); + SharedTranslationsTestUtils._assertTabIndexOrder([ + settingsButton, + ...(AppConstants.platform === "win" + ? [tryAgainButton, cancelButton] + : [cancelButton, tryAgainButton]), + ]); + SharedTranslationsTestUtils._assertHasFocus(tryAgainButton); + } + + /** + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when a translation has failed to complete. + */ + static async assertPanelViewTranslationFailure() { + const { + cancelButton, + fromMenuList, + settingsButton, + toMenuList, + translationFailureMessageBar, + tryAgainButton, + } = SelectTranslationsPanel.elements; + await SelectTranslationsTestUtils.waitForPanelState("translation-failure"); + SelectTranslationsTestUtils.#assertPanelElementVisibility({ + header: true, + betaIcon: true, + cancelButton: true, + fromLabel: true, + fromMenuList: true, + mainContent: true, + settingsButton: true, + toLabel: true, + toMenuList: true, + translationFailureMessageBar: true, + tryAgainButton: true, + }); + is( + document.activeElement, + tryAgainButton, + "The try-again button should have focus." + ); + is( + translationFailureMessageBar.getAttribute("role"), + "alert", + "The translation failure message bar is an alert." + ); + SharedTranslationsTestUtils._assertTabIndexOrder([ + settingsButton, + fromMenuList, + toMenuList, + ...(AppConstants.platform === "win" + ? [tryAgainButton, cancelButton] + : [cancelButton, tryAgainButton]), + ]); + SharedTranslationsTestUtils._assertHasFocus(tryAgainButton); } static #assertPanelTextAreaDirection(langTag = null) { @@ -1571,23 +1773,65 @@ class SelectTranslationsTestUtils { } /** + * Asserts that the SelectTranslationsPanel UI matches the expected + * state when the panel has completed its translation. + */ + static async assertPanelViewUnsupportedLanguage() { + await SelectTranslationsTestUtils.waitForPanelState("unsupported"); + const { + doneButtonSecondary, + settingsButton, + translateButton, + tryAnotherSourceMenuList, + unsupportedLanguageMessageBar, + } = SelectTranslationsPanel.elements; + SelectTranslationsTestUtils.#assertPanelElementVisibility({ + betaIcon: true, + doneButtonSecondary: true, + header: true, + settingsButton: true, + translateButton: true, + tryAnotherSourceMenuList: true, + unsupportedLanguageContent: true, + unsupportedLanguageMessageBar: true, + }); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ + doneButtonSecondary: true, + translateButton: false, + }); + ok( + translateButton.disabled, + "The translate button should be disabled when first shown." + ); + SharedTranslationsTestUtils._assertL10nId( + unsupportedLanguageMessageBar, + "select-translations-panel-unsupported-language-message-known" + ); + SharedTranslationsTestUtils._assertHasFocus(tryAnotherSourceMenuList); + SharedTranslationsTestUtils._assertTabIndexOrder([ + settingsButton, + tryAnotherSourceMenuList, + doneButtonSecondary, + ]); + } + + /** * Asserts that the SelectTranslationsPanel translated text area is * both scrollable and scrolled to the top. */ - static #assertPanelTextAreaOverflow() { + static async #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." + if (textArea.style.overflow !== "auto") { + await BrowserTestUtils.waitForMutationCondition( + textArea, + { attributes: true, attributeFilter: ["style"] }, + () => textArea.style.overflow === "auto" ); } + if (textArea.scrollHeight > textArea.clientHeight) { + info("Ensuring that the textarea is scrolled to the top."); + await waitForCondition(() => textArea.scrollTop === 0); + } } /** @@ -1619,88 +1863,44 @@ class SelectTranslationsTestUtils { * Asserts that the SelectTranslationsPanel UI matches the expected * state when the panel is actively translating text. */ - static assertPanelViewActivelyTranslating() { + static async assertPanelViewActivelyTranslating() { const { textArea } = SelectTranslationsPanel.elements; + const isFullPageTranslationsRestrictedForPage = + TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser); + await SelectTranslationsTestUtils.waitForPanelState("translating"); ok( textArea.classList.contains("translating"), "The textarea should have the translating class." ); SelectTranslationsTestUtils.#assertPanelElementVisibility({ - ...SelectTranslationsTestUtils.#alwaysPresentElements, + betaIcon: true, + copyButton: true, + doneButtonPrimary: true, + fromLabel: true, + fromMenuList: true, + header: true, + mainContent: true, + settingsButton: true, + textArea: true, + toLabel: true, + toMenuList: true, + translateFullPageButton: !isFullPageTranslationsRestrictedForPage, }); 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 { textArea, fromMenuList, toMenuList } = + SelectTranslationsPanel.elements; const expected = await document.l10n.formatValue( "select-translations-panel-translating-placeholder-text" ); + const isFullPageTranslationsRestrictedForPage = + TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser); is( textArea.value, expected, @@ -1710,7 +1910,10 @@ class SelectTranslationsTestUtils { SelectTranslationsTestUtils.#assertConditionalUIEnabled({ textArea: true, copyButton: false, - translateFullPageButton: true, + doneButtonPrimary: true, + translateFullPageButton: + fromMenuList.value !== toMenuList.value && + !isFullPageTranslationsRestrictedForPage, }); } @@ -1723,6 +1926,27 @@ class SelectTranslationsTestUtils { SelectTranslationsPanel.elements; const fromLanguage = fromMenuList.value; const toLanguage = toMenuList.value; + const isFullPageTranslationsRestrictedForPage = + TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser); + + SelectTranslationsTestUtils.#assertPanelTextAreaDirection(toLanguage); + SelectTranslationsTestUtils.#assertConditionalUIEnabled({ + textArea: true, + copyButton: true, + doneButtonPrimary: true, + translateFullPageButton: + fromLanguage !== toLanguage && !isFullPageTranslationsRestrictedForPage, + }); + + if (fromLanguage === toLanguage) { + is( + SelectTranslationsPanel.getSourceText(), + SelectTranslationsPanel.getTranslatedText(), + "The source text should passthrough as the translated text." + ); + return; + } + const translatedSuffix = ` [${fromLanguage} to ${toLanguage}]`; ok( textArea.value.endsWith(translatedSuffix), @@ -1734,45 +1958,32 @@ class SelectTranslationsTestUtils { translatedSuffix.length, "Expected translated text length to correspond to the source text length." ); - SelectTranslationsTestUtils.#assertPanelTextAreaDirection(toLanguage); - SelectTranslationsTestUtils.#assertConditionalUIEnabled({ - textArea: true, - copyButton: 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). + * @param {Record<string, boolean>} enabledStates + * - An object that maps whether each element should be enabled (true) or disabled (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" - }.` - ); + static #assertConditionalUIEnabled(enabledStates) { + const elements = SelectTranslationsPanel.elements; + + for (const [elementName, expectEnabled] of Object.entries(enabledStates)) { + const element = elements[elementName]; + if (!element) { + throw new Error( + `SelectTranslationsPanel element '${elementName}' not found.` + ); + } + is( + element.disabled, + !expectEnabled, + `The element '${elementName} should be ${ + expectEnabled ? "enabled" : "disabled" + }.` + ); + } } /** @@ -1820,22 +2031,235 @@ class SelectTranslationsTestUtils { */ static async clickDoneButton() { logAction(); - const { doneButton } = SelectTranslationsPanel.elements; - assertVisibility({ visible: { doneButton } }); + const { doneButtonPrimary, doneButtonSecondary } = + SelectTranslationsPanel.elements; + let visibleDoneButton; + let hiddenDoneButton; + if (BrowserTestUtils.isVisible(doneButtonPrimary)) { + visibleDoneButton = doneButtonPrimary; + hiddenDoneButton = doneButtonSecondary; + } else if (BrowserTestUtils.isVisible(doneButtonSecondary)) { + visibleDoneButton = doneButtonSecondary; + hiddenDoneButton = doneButtonPrimary; + } else { + throw new Error( + "Expected either the primary or secondary done button to be visible." + ); + } + assertVisibility({ + visible: { visibleDoneButton }, + hidden: { hiddenDoneButton }, + }); + await SelectTranslationsTestUtils.waitForPanelPopupEvent( + "popuphidden", + () => { + click(visibleDoneButton, "Clicking the done button"); + } + ); + } + + /** + * Simulates clicking the cancel button and waits for the panel to close. + */ + static async clickCancelButton() { + logAction(); + const { cancelButton } = SelectTranslationsPanel.elements; + assertVisibility({ visible: { cancelButton } }); await SelectTranslationsTestUtils.waitForPanelPopupEvent( "popuphidden", () => { - click(doneButton, "Clicking the done button"); + click(cancelButton, "Clicking the cancel button"); } ); } /** + * Simulates clicking the copy button and asserts that all relevant states are correctly updated. + */ + static async clickCopyButton() { + logAction(); + const { copyButton } = SelectTranslationsPanel.elements; + + assertVisibility({ visible: { copyButton } }); + is( + SelectTranslationsPanel.phase(), + "translated", + 'The copy button should only be clickable in the "translated" phase' + ); + + click(copyButton, "Clicking the copy button"); + await waitForCondition( + () => + copyButton.classList.contains("copied") && + copyButton.getAttribute("data-l10n-id") === + "select-translations-panel-copy-button-copied", + "Waiting for copy button to match the copied state." + ); + + const copiedText = SpecialPowers.getClipboardData("text/plain"); + is( + // Because of differences in the clipboard code on Windows, we are going + // to explicitly sanitize carriage returns here when checking equality. + copiedText.replaceAll("\r", ""), + SelectTranslationsPanel.getTranslatedText().replaceAll("\r", ""), + "The clipboard should contain the translated text." + ); + } + + /** + * Simulates clicking the Translate button in the SelectTranslationsPanel, + * then waits for any pending translation effects, based on the provided options. + * + * @param {object} config + * @param {Function} [config.downloadHandler] + * - The function handle expected downloads, resolveDownloads() or rejectDownloads() + * Leave as null to test more granularly, such as testing opening the loading view, + * or allowing for the automatic downloading of files. + * @param {boolean} [config.pivotTranslation] + * - True if the expected translation is a pivot translation, otherwise false. + * Affects the number of expected downloads. + * @param {Function} [config.viewAssertion] + * - An optional callback function to execute for asserting the panel UI state. + */ + static async clickTranslateButton({ + downloadHandler, + pivotTranslation, + viewAssertion, + }) { + logAction(); + const { + doneButtonSecondary, + settingsButton, + translateButton, + tryAnotherSourceMenuList, + } = SelectTranslationsPanel.elements; + assertVisibility({ visible: { doneButtonPrimary: translateButton } }); + + ok(!translateButton.disabled, "The translate button should be enabled."); + SharedTranslationsTestUtils._assertTabIndexOrder([ + settingsButton, + tryAnotherSourceMenuList, + ...(AppConstants.platform === "win" + ? [translateButton, doneButtonSecondary] + : [doneButtonSecondary, translateButton]), + ]); + + click(translateButton); + await SelectTranslationsTestUtils.waitForPanelState("translatable"); + if (downloadHandler) { + await this.handleDownloads({ downloadHandler, pivotTranslation }); + } + if (viewAssertion) { + await viewAssertion(); + } + } + + /** + * Simulates clicking the translate-full-page button. + */ + static async clickTranslateFullPageButton() { + logAction(); + const { translateFullPageButton } = SelectTranslationsPanel.elements; + assertVisibility({ visible: { translateFullPageButton } }); + click(translateFullPageButton); + await FullPageTranslationsTestUtils.assertTranslationsButton( + { button: true, circleArrows: true, locale: false, icon: true }, + "The icon presents the loading indicator." + ); + } + + /** + * Simulates clicking the try-again button. + * + * @param {object} config + * @param {Function} [config.downloadHandler] + * - The function handle expected downloads, resolveDownloads() or rejectDownloads() + * Leave as null to test more granularly, such as testing opening the loading view, + * or allowing for the automatic downloading of files. + * @param {boolean} [config.pivotTranslation] + * - True if the expected translation is a pivot translation, otherwise false. + * Affects the number of expected downloads. + * @param {Function} [config.viewAssertion] + * - An optional callback function to execute for asserting the panel UI state. + */ + static async clickTryAgainButton({ + downloadHandler, + pivotTranslation, + viewAssertion, + } = {}) { + logAction(); + const { tryAgainButton } = SelectTranslationsPanel.elements; + assertVisibility({ visible: { tryAgainButton } }); + click(tryAgainButton, "Clicking the try-again button"); + await SelectTranslationsTestUtils.waitForPanelState("translatable"); + if (downloadHandler) { + await this.handleDownloads({ downloadHandler, pivotTranslation }); + } + if (viewAssertion) { + await viewAssertion(); + } + } + + /** + * Opens the SelectTranslationsPanel settings menu. + * Requires that the translations panel is already open. + */ + static async openPanelSettingsMenu() { + logAction(); + const { settingsButton } = SelectTranslationsPanel.elements; + assertVisibility({ visible: { settingsButton } }); + await SharedTranslationsTestUtils._waitForPopupEvent( + "select-translations-panel-settings-menupopup", + "popupshown", + () => click(settingsButton, "Opening the settings menu") + ); + const settingsPageMenuItem = document.getElementById( + "select-translations-panel-open-settings-page-menuitem" + ); + const aboutTranslationsMenuItem = document.getElementById( + "select-translations-panel-about-translations-menuitem" + ); + + assertVisibility({ + visible: { + settingsPageMenuItem, + aboutTranslationsMenuItem, + }, + }); + } + + /** + * Clicks the SelectTranslationsPanel settings menu item + * that leads to the Translations Settings in about:preferences. + */ + static clickTranslationsSettingsPageMenuItem() { + logAction(); + const settingsPageMenuItem = document.getElementById( + "select-translations-panel-open-settings-page-menuitem" + ); + assertVisibility({ visible: { settingsPageMenuItem } }); + click(settingsPageMenuItem); + } + + /** * Opens the context menu at a specified element on the page, based on the provided options. * * @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.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. + * + * The following options will work on all test pages that have an <h1> element. + * + * @param {boolean} options.selectH1 - Selects the first H1 element of the page. + * @param {boolean} options.openAtH1 - Opens the context menu at the first H1 element of the page. + * + * The following options will work only in the PDF_TEST_PAGE_URL. + * + * @param {boolean} options.selectPdfSpan - Selects the first span of text on the first page of a pdf. + * @param {boolean} options.openAtPdfSpan - Opens the context menu at the first span of text on the first page of a pdf. + * * The following options will only work when testing SELECT_TEST_PAGE_URL. * * @param {boolean} options.selectFrenchSection - Selects the section of French text. @@ -1868,8 +2292,8 @@ class SelectTranslationsTestUtils { const selectorFunction = TranslationsTest.getSelectors()[data.selectorFunctionName]; if (typeof selectorFunction === "function") { - const paragraph = selectorFunction(); - TranslationsTest.selectContentElement(paragraph); + const element = await selectorFunction(); + TranslationsTest.selectContentElement(element); } }, { selectorFunctionName } @@ -1877,6 +2301,8 @@ class SelectTranslationsTestUtils { } }; + await maybeSelectContentFrom("H1"); + await maybeSelectContentFrom("PdfSpan"); await maybeSelectContentFrom("FrenchSection"); await maybeSelectContentFrom("EnglishSection"); await maybeSelectContentFrom("SpanishSection"); @@ -1898,7 +2324,7 @@ class SelectTranslationsTestUtils { const selectorFunction = TranslationsTest.getSelectors()[data.selectorFunctionName]; if (typeof selectorFunction === "function") { - const element = selectorFunction(); + const element = await selectorFunction(); await TranslationsTest.rightClickContentElement(element); } }, @@ -1909,6 +2335,8 @@ class SelectTranslationsTestUtils { } }; + await maybeOpenContextMenuAt("H1"); + await maybeOpenContextMenuAt("PdfSpan"); await maybeOpenContextMenuAt("FrenchSection"); await maybeOpenContextMenuAt("EnglishSection"); await maybeOpenContextMenuAt("SpanishSection"); @@ -1931,28 +2359,10 @@ class SelectTranslationsTestUtils { * @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 (textArea.style.overflow === "hidden") { - await BrowserTestUtils.waitForMutationCondition( - textArea, - { attributes: true, attributeFilter: ["style"] }, - () => textArea.style.overflow === "auto" - ); - } } /** @@ -1965,6 +2375,7 @@ class SelectTranslationsTestUtils { * @returns {Promise<void>} */ static async changeSelectedFromLanguage(langTags, options) { + logAction(langTags); const { fromMenuList, fromMenuPopup } = SelectTranslationsPanel.elements; const { openDropdownMenu } = options; @@ -1980,6 +2391,28 @@ class SelectTranslationsTestUtils { } /** + * Change the selected language in the try-another-source-language dropdown. + * + * @param {string} langTag - A BCP-47 language tag. + */ + static async changeSelectedTryAnotherSourceLanguage(langTag) { + logAction(langTag); + const { tryAnotherSourceMenuList, translateButton } = + SelectTranslationsPanel.elements; + await SelectTranslationsTestUtils.#changeSelectedLanguageDirectly( + [langTag], + { menuList: tryAnotherSourceMenuList }, + { + onChangeLanguage: () => + ok( + !translateButton.disabled, + "The translate button should be enabled after selecting a language." + ), + } + ); + } + + /** * Switches the selected to-language to the provided language tag. * * @param {string[]} langTags - An array of BCP-47 language tags. @@ -1991,6 +2424,7 @@ class SelectTranslationsTestUtils { * @returns {Promise<void>} */ static async changeSelectedToLanguage(langTags, options) { + logAction(langTags); const { toMenuList, toMenuPopup } = SelectTranslationsPanel.elements; const { openDropdownMenu } = options; @@ -2019,6 +2453,7 @@ class SelectTranslationsTestUtils { */ static async #changeSelectedLanguageDirectly(langTags, elements, options) { const { menuList } = elements; + const { textArea } = SelectTranslationsPanel.elements; const { onChangeLanguage, downloadHandler } = options; for (const langTag of langTags) { @@ -2028,14 +2463,23 @@ class SelectTranslationsTestUtils { () => menuList.value === langTag ); + menuList.focus(); menuList.value = langTag; menuList.dispatchEvent(new Event("command")); await menuListUpdated; } - if (downloadHandler) { - menuList.focus(); + // Either of these events should trigger a translation after the selected + // language has been changed directly. + if (Math.random() < 0.5) { + info("Attempting to trigger translation via text-area focus."); + textArea.focus(); + } else { + info("Attempting to trigger translation via pressing Enter."); EventUtils.synthesizeKey("KEY_Enter"); + } + + if (downloadHandler) { await SelectTranslationsTestUtils.handleDownloads(options); } diff --git a/browser/components/uitour/test/browser_UITour5.js b/browser/components/uitour/test/browser_UITour5.js index 50316d4225..ebb0811440 100644 --- a/browser/components/uitour/test/browser_UITour5.js +++ b/browser/components/uitour/test/browser_UITour5.js @@ -41,7 +41,7 @@ add_UITour_task(async function test_highlight_help_and_show_help_subview() { let helpButtonID = "appMenu-help-button2"; let helpBtn = document.getElementById(helpButtonID); - helpBtn.dispatchEvent(new Event("command")); + helpBtn.dispatchEvent(new Event("command", { bubbles: true })); await highlightHiddenPromise; await ViewShownPromise; let helpView = document.getElementById("PanelUI-helpView"); diff --git a/browser/components/urlbar/ActionsProvider.sys.mjs b/browser/components/urlbar/ActionsProvider.sys.mjs new file mode 100644 index 0000000000..9cd99969a2 --- /dev/null +++ b/browser/components/urlbar/ActionsProvider.sys.mjs @@ -0,0 +1,110 @@ +/* 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/. */ + +/** + * A provider that matches the urlbar input to built in actions. + */ +export class ActionsProvider { + /** + * Unique name for the provider. + * + * @abstract + */ + get name() { + return "ActionsProviderBase"; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} _queryContext The query context object. + * @returns {boolean} Whether this provider should be invoked for the search. + * @abstract + */ + isActive(_queryContext) { + throw new Error("Not implemented."); + } + + /** + * Query for actions based on the current users input. + * + * @param {UrlbarQueryContext} _queryContext The query context object. + * @param {UrlbarController} _controller The urlbar controller. + * @returns {ActionsResult} + * @abstract + */ + async queryAction(_queryContext, _controller) { + throw new Error("Not implemented."); + } + + /** + * Pick an action. + * + * @param {UrlbarQueryContext} _queryContext The query context object. + * @param {UrlbarController} _controller The urlbar controller. + * @param {DOMElement} _element The element that was selected. + * @abstract + */ + pickAction(_queryContext, _controller, _element) { + throw new Error("Not implemented."); + } +} + +/** + * Class used to create an Actions Result. + */ +export class ActionsResult { + providerName; + + #key; + #l10nId; + #l10nArgs; + #icon; + #dataset; + + /** + * @param {object} options + * An option object. + * @param { string } options.key + * A string key used to distinguish between different actions. + * @param { string } options.l10nId + * The id of the l10n string displayed in the action button. + * @param { string } options.l10nArgs + * Arguments passed to construct the above string + * @param { string } options.icon + * The icon displayed in the button. + * @param {object} options.dataset + * An object of properties we set on the action button that + * can be used to pass data when it is selected. + */ + constructor({ key, l10nId, l10nArgs, icon, dataset }) { + this.#key = key; + this.#l10nId = l10nId; + this.#l10nArgs = l10nArgs; + this.#icon = icon; + this.#dataset = dataset; + } + + get key() { + return this.#key; + } + + get l10nId() { + return this.#l10nId; + } + + get l10nArgs() { + return this.#l10nArgs; + } + + get icon() { + return this.#icon; + } + + get dataset() { + return this.#dataset; + } +} diff --git a/browser/components/urlbar/ActionsProviderContextualSearch.sys.mjs b/browser/components/urlbar/ActionsProviderContextualSearch.sys.mjs new file mode 100644 index 0000000000..58af7c94c5 --- /dev/null +++ b/browser/components/urlbar/ActionsProviderContextualSearch.sys.mjs @@ -0,0 +1,121 @@ +/* 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 { UrlbarUtils } from "resource:///modules/UrlbarUtils.sys.mjs"; + +import { + ActionsProvider, + ActionsResult, +} from "resource:///modules/ActionsProvider.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.sys.mjs", + loadAndParseOpenSearchEngine: + "resource://gre/modules/OpenSearchLoader.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +const ENABLED_PREF = "contextualSearch.enabled"; + +/** + * A provider that returns an option for using the search engine provided + * by the active view if it utilizes OpenSearch. + */ +class ProviderContextualSearch extends ActionsProvider { + constructor() { + super(); + this.engines = new Map(); + } + + get name() { + return "ActionsProviderContextualSearch"; + } + + isActive(queryContext) { + return ( + queryContext.trimmedSearchString && + lazy.UrlbarPrefs.get(ENABLED_PREF) && + !queryContext.searchMode + ); + } + + async queryAction(queryContext, controller) { + let instance = this.queryInstance; + const hostname = URL.parse(queryContext.currentPage)?.hostname; + + // This happens on about pages, which won't have associated engines + if (!hostname) { + return null; + } + + let engine = await this.fetchEngine(controller); + let icon = engine?.icon || (await engine?.getIconURL?.()); + let defaultEngine = lazy.UrlbarSearchUtils.getDefaultEngine(); + + if ( + !engine || + engine.name === defaultEngine?.name || + instance != this.queryInstance + ) { + return null; + } + + return new ActionsResult({ + key: "contextual-search", + l10nId: "urlbar-result-search-with", + l10nArgs: { engine: engine.name || engine.title }, + icon, + }); + } + + async fetchEngine(controller) { + let browser = controller.browserWindow.gBrowser.selectedBrowser; + let hostname = browser?.currentURI.host; + + if (this.engines.has(hostname)) { + return this.engines.get(hostname); + } + + // Strip www. to allow for partial matches when looking for an engine. + const [host] = UrlbarUtils.stripPrefixAndTrim(hostname, { + stripWww: true, + }); + let engines = await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host, { + matchAllDomainLevels: true, + }); + return engines[0] ?? browser?.engines?.[0]; + } + + async pickAction(queryContext, controller, element) { + // If we have an engine to add, first create a new OpenSearchEngine, then + // get and open a url to execute a search for the term in the url bar. + let engine = await this.fetchEngine(controller); + + if (engine.uri) { + let engineData = await lazy.loadAndParseOpenSearchEngine( + Services.io.newURI(engine.uri) + ); + engine = new lazy.OpenSearchEngine({ engineData }); + engine._setIcon(engine.icon, false); + } + + const [url] = UrlbarUtils.getSearchQueryUrl( + engine, + queryContext.searchString + ); + element.ownerGlobal.gBrowser.fixupAndLoadURIString(url, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + element.ownerGlobal.gBrowser.selectedBrowser.focus(); + } + + resetForTesting() { + this.engines = new Map(); + } +} + +export var ActionsProviderContextualSearch = new ProviderContextualSearch(); diff --git a/browser/components/urlbar/ActionsProviderQuickActions.sys.mjs b/browser/components/urlbar/ActionsProviderQuickActions.sys.mjs new file mode 100644 index 0000000000..a693e7686a --- /dev/null +++ b/browser/components/urlbar/ActionsProviderQuickActions.sys.mjs @@ -0,0 +1,152 @@ +/* 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 { + ActionsProvider, + ActionsResult, +} from "resource:///modules/ActionsProvider.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + QuickActionsLoaderDefault: + "resource:///modules/QuickActionsLoaderDefault.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", +}); + +// These prefs are relative to the `browser.urlbar` branch. +const ENABLED_PREF = "quickactions.enabled"; +const MATCH_IN_PHRASE_PREF = "quickactions.matchInPhrase"; +const MIN_SEARCH_PREF = "quickactions.minimumSearchString"; + +/** + * A provider that matches the urlbar input to built in actions. + */ +class ProviderQuickActions extends ActionsProvider { + get name() { + return "ActionsProviderQuickActions"; + } + + isActive(queryContext) { + return ( + lazy.UrlbarPrefs.get(ENABLED_PREF) && + !queryContext.searchMode && + queryContext.trimmedSearchString.length < 50 && + queryContext.trimmedSearchString.length > + lazy.UrlbarPrefs.get(MIN_SEARCH_PREF) + ); + } + + async queryAction(queryContext) { + await lazy.QuickActionsLoaderDefault.ensureLoaded(); + let input = queryContext.trimmedLowerCaseSearchString; + let results = [...(this.#prefixes.get(input) ?? [])]; + + if (lazy.UrlbarPrefs.get(MATCH_IN_PHRASE_PREF)) { + for (let [keyword, key] of this.#keywords) { + if (input.includes(keyword)) { + results.push(key); + } + } + } + + // Remove invisible actions. + results = results.filter(key => { + const action = this.#actions.get(key); + return action.isVisible?.() ?? true; + }); + + if (!results.length) { + return null; + } + + let action = this.#actions.get(results[0]); + return new ActionsResult({ + key: results[0], + l10nId: action.label, + icon: action.icon, + dataset: { + action: results[0], + inputLength: queryContext.trimmedSearchString.length, + }, + }); + } + + pickAction(_queryContext, _controller, element) { + let action = element.dataset.action; + let inputLength = Math.min(element.dataset.inputLength, 10); + Services.telemetry.keyedScalarAdd( + `quickaction.picked`, + `${action}-${inputLength}`, + 1 + ); + let options = this.#actions.get(action).onPick(); + if (options?.focusContent) { + element.ownerGlobal.gBrowser.selectedBrowser.focus(); + } + } + + /** + * Adds a new QuickAction. + * + * @param {string} key A key to identify this action. + * @param {string} definition An object that describes the action. + */ + addAction(key, definition) { + this.#actions.set(key, definition); + definition.commands.forEach(cmd => this.#keywords.set(cmd, key)); + this.#loopOverPrefixes(definition.commands, prefix => { + let result = this.#prefixes.get(prefix); + if (result) { + if (!result.includes(key)) { + result.push(key); + } + } else { + result = [key]; + } + this.#prefixes.set(prefix, result); + }); + } + + /** + * Removes an action. + * + * @param {string} key A key to identify this action. + */ + removeAction(key) { + let definition = this.#actions.get(key); + this.#actions.delete(key); + definition.commands.forEach(cmd => this.#keywords.delete(cmd)); + this.#loopOverPrefixes(definition.commands, prefix => { + let result = this.#prefixes.get(prefix); + if (result) { + result = result.filter(val => val != key); + } + this.#prefixes.set(prefix, result); + }); + } + + // A map from keywords to an action. + #keywords = new Map(); + + // A map of all prefixes to an array of actions. + #prefixes = new Map(); + + // The actions that have been added. + #actions = new Map(); + + #loopOverPrefixes(commands, fun) { + for (const command of commands) { + // Loop over all the prefixes of the word, ie + // "", "w", "wo", "wor", stopping just before the full + // word itself which will be matched by the whole + // phrase matching. + for (let i = 1; i <= command.length; i++) { + let prefix = command.substring(0, command.length - i); + fun(prefix); + } + } + } +} + +export var ActionsProviderQuickActions = new ProviderQuickActions(); diff --git a/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs b/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs index 0ab9c4c83e..9a7b7a6a34 100644 --- a/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs +++ b/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs @@ -8,12 +8,10 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", - ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", - UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", + ActionsProviderQuickActions: + "resource:///modules/ActionsProviderQuickActions.sys.mjs", }); import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; @@ -26,7 +24,6 @@ if (AppConstants.MOZ_UPDATER) { "nsIApplicationUpdateService" ); } - XPCOMUtils.defineLazyPreferenceGetter( lazy, "SCREENSHOT_BROWSER_COMPONENT", @@ -54,7 +51,7 @@ let openUrl = url => { let openAddonsUrl = url => { return () => { let window = lazy.BrowserWindowTracker.getTopWindow(); - window.BrowserOpenAddonsMgr(url, { selectTabByViewId: true }); + window.BrowserAddonUI.openAddonsMgr(url, { selectTabByViewId: true }); }; }; @@ -97,24 +94,22 @@ const DEFAULT_ACTIONS = { }, }, downloads: { - l10nCommands: ["quickactions-cmd-downloads", "quickactions-downloads2"], + l10nCommands: ["quickactions-cmd-downloads"], icon: "chrome://browser/skin/downloads/downloads.svg", label: "quickactions-downloads2", onPick: openUrlFun("about:downloads"), }, extensions: { - l10nCommands: ["quickactions-cmd-extensions", "quickactions-extensions"], + l10nCommands: ["quickactions-cmd-extensions"], icon: "chrome://mozapps/skin/extensions/category-extensions.svg", label: "quickactions-extensions", onPick: openAddonsUrl("addons://list/extension"), }, inspect: { - l10nCommands: ["quickactions-cmd-inspector", "quickactions-inspector2"], + l10nCommands: ["quickactions-cmd-inspector"], icon: "chrome://devtools/skin/images/open-inspector.svg", label: "quickactions-inspector2", - isVisible: () => - lazy.DevToolsShim.isEnabled() || lazy.DevToolsShim.isDevToolsUser(), - isActive: () => { + isVisible: () => { // The inspect action is available if: // 1. DevTools is enabled. // 2. The user can be considered as a DevTools user. @@ -132,18 +127,18 @@ const DEFAULT_ACTIONS = { onPick: openInspector, }, logins: { - l10nCommands: ["quickactions-cmd-logins", "quickactions-logins2"], + l10nCommands: ["quickactions-cmd-logins"], label: "quickactions-logins2", onPick: openUrlFun("about:logins"), }, plugins: { - l10nCommands: ["quickactions-cmd-plugins", "quickactions-plugins"], + l10nCommands: ["quickactions-cmd-plugins"], icon: "chrome://mozapps/skin/extensions/category-extensions.svg", label: "quickactions-plugins", onPick: openAddonsUrl("addons://list/plugin"), }, print: { - l10nCommands: ["quickactions-cmd-print", "quickactions-print2"], + l10nCommands: ["quickactions-cmd-print"], label: "quickactions-print2", icon: "chrome://global/skin/icons/print.svg", onPick: () => { @@ -153,7 +148,7 @@ const DEFAULT_ACTIONS = { }, }, private: { - l10nCommands: ["quickactions-cmd-private", "quickactions-private2"], + l10nCommands: ["quickactions-cmd-private"], label: "quickactions-private2", icon: "chrome://global/skin/icons/indicator-private-browsing.svg", onPick: () => { @@ -163,7 +158,7 @@ const DEFAULT_ACTIONS = { }, }, refresh: { - l10nCommands: ["quickactions-cmd-refresh", "quickactions-refresh"], + l10nCommands: ["quickactions-cmd-refresh"], label: "quickactions-refresh", onPick: () => { lazy.ResetProfile.openConfirmationDialog( @@ -172,7 +167,7 @@ const DEFAULT_ACTIONS = { }, }, restart: { - l10nCommands: ["quickactions-cmd-restart", "quickactions-restart"], + l10nCommands: ["quickactions-cmd-restart"], label: "quickactions-restart", onPick: restartBrowser, }, @@ -197,10 +192,10 @@ const DEFAULT_ACTIONS = { }, }, screenshot: { - l10nCommands: ["quickactions-cmd-screenshot", "quickactions-screenshot3"], + l10nCommands: ["quickactions-cmd-screenshot"], label: "quickactions-screenshot3", icon: "chrome://browser/skin/screenshot.svg", - isActive: () => { + isVisible: () => { return !lazy.BrowserWindowTracker.getTopWindow().gScreenshots.shouldScreenshotsButtonBeDisabled(); }, onPick: () => { @@ -221,21 +216,21 @@ const DEFAULT_ACTIONS = { }, }, settings: { - l10nCommands: ["quickactions-cmd-settings", "quickactions-settings2"], + l10nCommands: ["quickactions-cmd-settings"], icon: "chrome://global/skin/icons/settings.svg", label: "quickactions-settings2", onPick: openUrlFun("about:preferences"), }, themes: { - l10nCommands: ["quickactions-cmd-themes", "quickactions-themes"], + l10nCommands: ["quickactions-cmd-themes"], icon: "chrome://mozapps/skin/extensions/category-extensions.svg", label: "quickactions-themes", onPick: openAddonsUrl("addons://list/theme"), }, update: { - l10nCommands: ["quickactions-cmd-update", "quickactions-update"], + l10nCommands: ["quickactions-cmd-update"], label: "quickactions-update", - isActive: () => { + isVisible: () => { if (!AppConstants.MOZ_UPDATER) { return false; } @@ -246,10 +241,10 @@ const DEFAULT_ACTIONS = { onPick: restartBrowser, }, viewsource: { - l10nCommands: ["quickactions-cmd-viewsource", "quickactions-viewsource2"], + l10nCommands: ["quickactions-cmd-viewsource"], icon: "chrome://global/skin/icons/settings.svg", label: "quickactions-viewsource2", - isActive: () => currentBrowser()?.currentURI.scheme !== "view-source", + isVisible: () => currentBrowser()?.currentURI.scheme !== "view-source", onPick: () => openUrl("view-source:" + currentBrowser().currentURI.spec), }, }; @@ -287,18 +282,6 @@ function restartBrowser() { } } -function random(seed) { - let x = Math.sin(seed) * 10000; - return x - Math.floor(x); -} - -function shuffle(array, seed) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(random(seed) * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } -} - /** * Loads the default QuickActions. */ @@ -308,18 +291,6 @@ export class QuickActionsLoaderDefault { static async load() { let keys = Object.keys(DEFAULT_ACTIONS); - if (lazy.UrlbarPrefs.get("quickactions.randomOrderActions")) { - // We insert the actions in a random order which means they will be returned - // in a random but consistent order (the order of results for "view" and "views" - // should be the same). - // We use the Nimbus randomizationId as the seed as the order should not change - // for the user between restarts, it should be random between users but a user should - // see actions the same order. - let seed = [...lazy.ClientEnvironment.randomizationId] - .map(x => x.charCodeAt(0)) - .reduce((sum, a) => sum + a, 0); - shuffle(keys, seed); - } for (const key of keys) { let actionData = DEFAULT_ACTIONS[key]; let messages = await lazy.gFluentStrings.formatMessages( @@ -328,7 +299,7 @@ export class QuickActionsLoaderDefault { actionData.commands = messages .map(({ value }) => value.split(",").map(x => x.trim().toLowerCase())) .flat(); - lazy.UrlbarProviderQuickActions.addAction(key, actionData); + lazy.ActionsProviderQuickActions.addAction(key, actionData); } } static async ensureLoaded() { diff --git a/browser/components/urlbar/UrlbarController.sys.mjs b/browser/components/urlbar/UrlbarController.sys.mjs index 7e4d0ff1c5..5172c14943 100644 --- a/browser/components/urlbar/UrlbarController.sys.mjs +++ b/browser/components/urlbar/UrlbarController.sys.mjs @@ -1001,7 +1001,6 @@ class TelemetryEvent { searchWords, searchSource, searchMode, - selectedElement, selIndex, selType, } @@ -1045,11 +1044,7 @@ class TelemetryEvent { currentResults[selIndex], selType ); - const selected_result_subtype = - lazy.UrlbarUtils.searchEngagementTelemetrySubtype( - currentResults[selIndex], - selectedElement - ); + const selected_result_subtype = ""; if (selected_result === "input_field" && !this._controller.view?.isOpen) { numResults = 0; diff --git a/browser/components/urlbar/UrlbarInput.sys.mjs b/browser/components/urlbar/UrlbarInput.sys.mjs index a96e862cff..2ee463dda8 100644 --- a/browser/components/urlbar/UrlbarInput.sys.mjs +++ b/browser/components/urlbar/UrlbarInput.sys.mjs @@ -11,6 +11,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs", + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", ExtensionSearchHandler: "resource://gre/modules/ExtensionSearchHandler.sys.mjs", ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", @@ -24,6 +25,7 @@ ChromeUtils.defineESModuleGetters(lazy, { UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", UrlbarQueryContext: "resource:///modules/UrlbarUtils.sys.mjs", UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", @@ -330,8 +332,6 @@ export class UrlbarInput { } setSelectionRange(selectionStart, selectionEnd) { - this.focus(); - let beforeSelect = new CustomEvent("beforeselect", { bubbles: true, cancelable: true, @@ -348,6 +348,32 @@ export class UrlbarInput { this._suppressPrimaryAdjustment = false; } + saveSelectionStateForBrowser(browser) { + let state = this.#browserStates.get(browser); + if (!state) { + state = {}; + this.#browserStates.set(browser, state); + } + state.selection = { + start: this.selectionStart, + end: this.selectionEnd, + // When restoring a URI from an empty value, we don't want to untrim it. + shouldUntrim: this.value && !this._protocolIsTrimmed, + }; + } + + restoreSelectionStateForBrowser(browser) { + // Address bar must be focused to untrim and for selection to make sense. + this.focus(); + let state = this.#browserStates.get(browser); + if (state?.selection) { + if (state.selection.shouldUntrim) { + this.#maybeUntrimUrl(); + } + this.setSelectionRange(state.selection.start, state.selection.end); + } + } + /** * Sets the URI to display in the location bar. * @@ -900,6 +926,15 @@ export class UrlbarInput { if (!result) { return; } + if (element?.dataset.action && element?.dataset.action != "tabswitch") { + this.view.close(); + let provider = lazy.UrlbarProvidersManager.getActionProvider( + element.dataset.providerName + ); + let { queryContext } = this.controller._lastQueryContextWrapper || {}; + provider.pickAction(queryContext, this.controller, element); + return; + } this.pickResult(result, event, element); } @@ -1053,7 +1088,13 @@ export class UrlbarInput { break; } case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: { - if (this.hasAttribute("action-override")) { + // Behaviour is reversed with SecondaryActions, default behaviour is to navigate + // and button is provided to switch to tab. + if ( + this.hasAttribute("action-override") || + (lazy.UrlbarPrefs.get("secondaryActions.featureGate") && + element?.dataset.action !== "tabswitch") + ) { where = "current"; break; } @@ -2268,22 +2309,32 @@ export class UrlbarInput { * @param {string} val The new value to set. * @param {object} [options] Options for setting. * @param {boolean} [options.allowTrim] Whether the value can be trimmed. + * @param {string} [options.untrimmedValue] Override for this._untrimmedValue. + * @param {boolean} [options.valueIsTyped] Override for this.valueIsTypede. + * * * @returns {string} The set value. */ - _setValue(val, { allowTrim = false } = {}) { + _setValue( + val, + { allowTrim = false, untrimmedValue = null, valueIsTyped = false } = {} + ) { // Don't expose internal about:reader URLs to the user. let originalUrl = lazy.ReaderMode.getOriginalUrlObjectForDisplay(val); if (originalUrl) { val = originalUrl.displaySpec; } - this._untrimmedValue = val; - + this._untrimmedValue = untrimmedValue ?? val; + this._protocolIsTrimmed = false; if (allowTrim) { + let oldVal = val; val = this._trimValue(val); + this._protocolIsTrimmed = + oldVal.startsWith(lazy.BrowserUIUtils.trimURLProtocol) && + !val.startsWith(lazy.BrowserUIUtils.trimURLProtocol); } - this.valueIsTyped = false; + this.valueIsTyped = valueIsTyped; this._resultForCurrentValue = null; this.inputField.value = val; this.formatValue(); @@ -2400,6 +2451,7 @@ export class UrlbarInput { selectionEnd: autofillValue.length, type: this._autofillPlaceholder.type, adaptiveHistoryInput: this._autofillPlaceholder.adaptiveHistoryInput, + untrimmedValue: this._autofillPlaceholder.untrimmedValue, }); } @@ -2725,6 +2777,8 @@ export class UrlbarInput { * @param {string} options.adaptiveHistoryInput * If the autofill type is "adaptive", this is the matching `input` value * from adaptive history. + * @param {string} options.untrimmedValue + * Untrimmed value including a protocol. */ _autofillValue({ value, @@ -2732,10 +2786,11 @@ export class UrlbarInput { selectionEnd, type, adaptiveHistoryInput, + untrimmedValue, }) { // The autofilled value may be a URL that includes a scheme at the // beginning. Do not allow it to be trimmed. - this._setValue(value); + this._setValue(value, { untrimmedValue }); this.inputField.setSelectionRange(selectionStart, selectionEnd); this._autofillPlaceholder = { value, @@ -2743,6 +2798,7 @@ export class UrlbarInput { adaptiveHistoryInput, selectionStart, selectionEnd, + untrimmedValue, }; } @@ -2993,7 +3049,7 @@ export class UrlbarInput { // pressed, open in current tab to allow ctrl-enter to canonize URL. where = "current"; } else { - where = this.window.whereToOpenLink(event, false, false); + where = lazy.BrowserUtils.whereToOpenLink(event, false, false); } if (lazy.UrlbarPrefs.get("openintab")) { if (where == "current") { @@ -3059,7 +3115,12 @@ export class UrlbarInput { // Error check occurs during isClipboardURIValid uri = Services.io.newURI(copyString); - strippedURI = lazy.QueryStringStripper.stripForCopyOrShare(uri); + try { + strippedURI = lazy.QueryStringStripper.stripForCopyOrShare(uri); + } catch (e) { + console.warn(`stripForCopyOrShare: ${e.message}`); + return uri; + } if (strippedURI) { return this.makeURIReadable(strippedURI); @@ -3087,6 +3148,59 @@ export class UrlbarInput { return true; } + /** + * Restores the untrimmed value in the urlbar. + */ + #maybeUntrimUrl() { + // Check if we can untrim the current value. + if ( + !lazy.UrlbarPrefs.get("untrimOnUserInteraction.featureGate") || + !this._protocolIsTrimmed || + !this.focused || + this.#allTextSelected + ) { + return; + } + + let selectionStart = this.selectionStart; + let selectionEnd = this.selectionEnd; + + // Correct the selection taking the trimmed protocol into account. + let offset = lazy.BrowserUIUtils.trimURLProtocol.length; + + // In case of autofill, we may have to adjust its boundaries. + if (this._autofillPlaceholder) { + this._autofillPlaceholder.selectionStart += offset; + this._autofillPlaceholder.selectionEnd += offset; + } + + if (selectionStart == selectionEnd) { + // When cursor is at the end of the string, untrimming may + // reintroduced a trailing slash and we want to move past it. + if (selectionEnd == this.value.length) { + offset += 1; + } + selectionStart = selectionEnd += offset; + } else { + // If there's a selection, we must calculate both the initial + // protocol and the eventual trailing slash. + if (selectionStart != 0) { + selectionStart += offset; + } + if (selectionEnd == this.value.length) { + offset += 1; + } + selectionEnd += offset; + } + + this._setValue(this._untrimmedValue, { + allowTrim: false, + valueIsTyped: this.valueIsTyped, + }); + + this.setSelectionRange(selectionStart, selectionEnd); + } + // The strip-on-share feature will strip known tracking/decorational // query params from the URI and copy the stripped version to the clipboard. _initStripOnShare() { @@ -3331,7 +3445,7 @@ export class UrlbarInput { if ( !this._preventClickSelectsAll && this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING && - this.document.activeElement == this.inputField && + this.focused && this.inputField.selectionStart == this.inputField.selectionEnd ) { this.select(); @@ -3381,14 +3495,17 @@ export class UrlbarInput { // If we were autofilling, remove the autofilled portion, by restoring // the value to the last typed one. this.value = this.window.gBrowser.userTypedValue; - } else if (this.value == this._focusUntrimmedValue) { + } else if ( + this.value == this._untrimmedValue && + !this.window.gBrowser.userTypedValue && + !this.focused + ) { // If the value was untrimmed by _on_focus and didn't change, trim it. - this.value = this._focusUntrimmedValue; + this.value = this._untrimmedValue; } else { // We're not updating the value, so just format it. this.formatValue(); } - this._focusUntrimmedValue = null; this._revertOnBlurValue = null; this._resetSearchState(); @@ -3446,6 +3563,7 @@ export class UrlbarInput { event.target.id == SEARCH_BUTTON_ID ) { this._maybeSelectAll(); + this.#maybeUntrimUrl(); } if (event.target == this._searchModeIndicatorClose && event.button != 2) { @@ -3487,7 +3605,7 @@ export class UrlbarInput { // This is necessary when a protocol was typed, but the whole url has // invalid parts, like the origin, then editing and confirming the trimmed // value would execute a search instead of visiting the typed url. - if (this.value != this._untrimmedValue) { + if (this._protocolIsTrimmed) { let untrim = false; let fixedURI = this._getURIFixupInfo(this.value)?.preferredURI; if (fixedURI) { @@ -3508,8 +3626,7 @@ export class UrlbarInput { } } if (untrim) { - this._focusUntrimmedValue = this._untrimmedValue; - this._setValue(this._focusUntrimmedValue); + this._setValue(this._untrimmedValue); } } @@ -3639,6 +3756,7 @@ export class UrlbarInput { let value = this.value; this.valueIsTyped = true; this._untrimmedValue = value; + this._protocolIsTrimmed = false; this._resultForCurrentValue = null; this.window.gBrowser.userTypedValue = value; @@ -3935,6 +4053,7 @@ export class UrlbarInput { } _on_keydown(event) { + this.#allTextSelectedOnKeyDown = this.#allTextSelected; if (event.keyCode === KeyEvent.DOM_VK_RETURN) { if (this._keyDownEnterDeferred) { this._keyDownEnterDeferred.reject(); @@ -3962,6 +4081,9 @@ export class UrlbarInput { } async _on_keyup(event) { + if (this.#allTextSelectedOnKeyDown) { + this.#maybeUntrimUrl(); + } if (event.keyCode === KeyEvent.DOM_VK_CONTROL) { this._isKeyDownWithCtrl = false; } @@ -4066,8 +4188,7 @@ export class UrlbarInput { // Only customize the drag data if the entire value is selected and it's a // loaded URI. Use default behavior otherwise. if ( - this.selectionStart != 0 || - this.selectionEnd != this.inputField.textLength || + !this.#allTextSelected || this.getAttribute("pageproxystate") != "valid" ) { return; @@ -4128,6 +4249,11 @@ export class UrlbarInput { this._initStripOnShare(); } + #allTextSelectedOnKeyDown = false; + get #allTextSelected() { + return this.selectionStart == 0 && this.selectionEnd == this.value.length; + } + /** * @param {string} value A untrimmed address bar input. * @returns {boolean} @@ -4148,6 +4274,12 @@ export class UrlbarInput { .nonWebControlledBlankURI ); } + + /** + * Tracks a state object per browser. + * TODO: Merge _searchModesByBrowser into this. + */ + #browserStates = new WeakMap(); } /** diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs index cd8a6b0f4c..264d86a3f4 100644 --- a/browser/components/urlbar/UrlbarPrefs.sys.mjs +++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs @@ -69,7 +69,7 @@ const PREF_URLBAR_DEFAULTS = new Map([ // Whether to show a link for using the search functionality provided by the // active view if the the view utilizes OpenSearch. - ["contextualSearch.enabled", false], + ["contextualSearch.enabled", true], // Whether using `ctrl` when hitting return/enter in the URL bar // (or clicking 'go') should prefix 'www.' and suffix @@ -178,7 +178,7 @@ const PREF_URLBAR_DEFAULTS = new Map([ // If disabled, QuickActions will not be included in either the default search // mode or the QuickActions search mode. - ["quickactions.enabled", false], + ["quickactions.enabled", true], // Whether we will match QuickActions within a phrase and not only a prefix. ["quickactions.matchInPhrase", true], @@ -188,9 +188,6 @@ const PREF_URLBAR_DEFAULTS = new Map([ // zero prefix state. ["quickactions.minimumSearchString", 3], - // Show multiple actions in a random order. - ["quickactions.randomOrderActions", false], - // Whether we show the Actions section in about:preferences. ["quickactions.showPrefs", false], @@ -322,11 +319,13 @@ const PREF_URLBAR_DEFAULTS = new Map([ // homepage is opened. ["searchTips.test.ignoreShowLimits", false], + // Feature gate pref for secondary actions being shown in the urlbar. + ["secondaryActions.featureGate", false], + // Whether to show each local search shortcut button in the view. ["shortcuts.bookmarks", true], ["shortcuts.tabs", true], ["shortcuts.history", true], - ["shortcuts.quickactions", false], // Boolean to determine if the providers defined in `exposureResults` // should be displayed in search results. This can be set by a @@ -464,6 +463,10 @@ const PREF_URLBAR_DEFAULTS = new Map([ // The index where we show unit conversion results. ["unitConversion.suggestedIndex", 1], + // Untrim url, when urlbar is focused. + // Note: This pref will be removed once the feature is stable. + ["untrimOnUserInteraction.featureGate", false], + // Controls the empty search behavior in Search Mode: // 0 - Show nothing // 1 - Show search history diff --git a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs index 7470df0fea..2ed1ee4444 100644 --- a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs @@ -68,7 +68,7 @@ const SQL_AUTOFILL_FRECENCY_THRESHOLD = `host_frecency >= ( )`; function originQuery(where) { - // `frecency`, `bookmarked` and `visited` are partitioned by the fixed host, + // `frecency`, `n_bookmarks` and `visited` are partitioned by the fixed host, // without `www.`. `host_prefix` instead is partitioned by full host, because // we assume a prefix may not work regardless of `www.`. let selectVisited = where.includes("visited") @@ -78,7 +78,7 @@ function originQuery(where) { : "0"; let selectTitle; let joinBookmarks; - if (where.includes("bookmarked")) { + if (where.includes("n_bookmarks")) { selectTitle = "ifnull(b.title, iif(h.frecency <> 0, h.title, NULL))"; joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = h.id"; } else { @@ -87,7 +87,7 @@ function originQuery(where) { } return `/* do not warn (bug no): cannot use an index to sort */ ${SQL_AUTOFILL_WITH}, - origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, bookmarked, visited) AS ( + origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, n_bookmarks, visited) AS ( SELECT id, prefix, @@ -96,11 +96,11 @@ function originQuery(where) { ), host, fixup_url(host), - IFNULL(total(${ORIGIN_FRECENCY_FIELD}) OVER (PARTITION BY fixup_url(host)), 0.0), + total(${ORIGIN_FRECENCY_FIELD}) OVER (PARTITION BY fixup_url(host)), ${ORIGIN_FRECENCY_FIELD}, - MAX(EXISTS( - SELECT 1 FROM moz_places WHERE origin_id = o.id AND foreign_count > 0 - )) OVER (PARTITION BY fixup_url(host)), + total( + (SELECT total(foreign_count) FROM moz_places WHERE origin_id = o.id) + ) OVER (PARTITION BY fixup_url(host)), ${selectVisited} FROM moz_origins o WHERE prefix NOT IN ('about:', 'place:') @@ -112,7 +112,7 @@ function originQuery(where) { ifnull(:prefix, host_prefix) || host || '/' FROM origins ${where} - ORDER BY frecency DESC, prefix = "https://" DESC, id DESC + ORDER BY frecency DESC, n_bookmarks DESC, prefix = "https://" DESC, id DESC LIMIT 1 ), matched_place(host_fixed, url, id, title, frecency) AS ( @@ -157,11 +157,11 @@ function urlQuery(where1, where2, isBookmarkContained) { joinBookmarks = ""; } return `/* do not warn (bug no): cannot use an index to sort */ - WITH matched_url(url, title, frecency, bookmarked, visited, stripped_url, is_exact_match, id) AS ( + WITH matched_url(url, title, frecency, n_bookmarks, visited, stripped_url, is_exact_match, id) AS ( SELECT url, title, frecency, - foreign_count > 0 AS bookmarked, + foreign_count AS n_bookmarks, visit_count > 0 AS visited, strip_prefix_and_userinfo(url) AS stripped_url, strip_prefix_and_userinfo(url) = strip_prefix_and_userinfo(:strippedURL) AS is_exact_match, @@ -173,7 +173,7 @@ function urlQuery(where1, where2, isBookmarkContained) { SELECT url, title, frecency, - foreign_count > 0 AS bookmarked, + foreign_count AS n_bookmarks, visit_count > 0 AS visited, strip_prefix_and_userinfo(url) AS stripped_url, strip_prefix_and_userinfo(url) = 'www.' || strip_prefix_and_userinfo(:strippedURL) AS is_exact_match, @@ -196,12 +196,12 @@ function urlQuery(where1, where2, isBookmarkContained) { // Queries const QUERY_ORIGIN_HISTORY_BOOKMARK = originQuery( - `WHERE bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` + `WHERE n_bookmarks > 0 OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` ); const QUERY_ORIGIN_PREFIX_HISTORY_BOOKMARK = originQuery( `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' - AND (bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})` + AND (n_bookmarks > 0 OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})` ); const QUERY_ORIGIN_HISTORY = originQuery( @@ -213,38 +213,38 @@ const QUERY_ORIGIN_PREFIX_HISTORY = originQuery( AND visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` ); -const QUERY_ORIGIN_BOOKMARK = originQuery(`WHERE bookmarked`); +const QUERY_ORIGIN_BOOKMARK = originQuery(`WHERE n_bookmarks > 0`); const QUERY_ORIGIN_PREFIX_BOOKMARK = originQuery( - `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' AND bookmarked` + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' AND n_bookmarks > 0` ); const QUERY_URL_HISTORY_BOOKMARK = urlQuery( - `AND (bookmarked OR frecency > 20) + `AND (n_bookmarks > 0 OR frecency > 20) AND stripped_url COLLATE NOCASE BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, - `AND (bookmarked OR frecency > 20) + `AND (n_bookmarks > 0 OR frecency > 20) AND stripped_url COLLATE NOCASE BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, true ); const QUERY_URL_PREFIX_HISTORY_BOOKMARK = urlQuery( - `AND (bookmarked OR frecency > 20) + `AND (n_bookmarks > 0 OR frecency > 20) AND url COLLATE NOCASE BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, - `AND (bookmarked OR frecency > 20) + `AND (n_bookmarks > 0 OR frecency > 20) AND url COLLATE NOCASE BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, true ); const QUERY_URL_HISTORY = urlQuery( - `AND (visited OR NOT bookmarked) + `AND (visited OR n_bookmarks = 0) AND frecency > 20 AND stripped_url COLLATE NOCASE BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, - `AND (visited OR NOT bookmarked) + `AND (visited OR n_bookmarks = 0) AND frecency > 20 AND stripped_url COLLATE NOCASE BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, @@ -252,11 +252,11 @@ const QUERY_URL_HISTORY = urlQuery( ); const QUERY_URL_PREFIX_HISTORY = urlQuery( - `AND (visited OR NOT bookmarked) + `AND (visited OR n_bookmarks = 0) AND frecency > 20 AND url COLLATE NOCASE BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, - `AND (visited OR NOT bookmarked) + `AND (visited OR n_bookmarks = 0) AND frecency > 20 AND url COLLATE NOCASE BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, @@ -264,20 +264,20 @@ const QUERY_URL_PREFIX_HISTORY = urlQuery( ); const QUERY_URL_BOOKMARK = urlQuery( - `AND bookmarked + `AND n_bookmarks > 0 AND stripped_url COLLATE NOCASE BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, - `AND bookmarked + `AND n_bookmarks > 0 AND stripped_url COLLATE NOCASE BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, true ); const QUERY_URL_PREFIX_BOOKMARK = urlQuery( - `AND bookmarked + `AND n_bookmarks > 0 AND url COLLATE NOCASE BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, - `AND bookmarked + `AND n_bookmarks > 0 AND url COLLATE NOCASE BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, true @@ -452,17 +452,19 @@ class ProviderAutofill extends UrlbarProvider { sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) ) { - conditions.push(`(bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})`); + conditions.push( + `(n_bookmarks > 0 OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})` + ); } else if (sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { conditions.push(`visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}`); } else if (sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { - conditions.push("bookmarked"); + conditions.push("n_bookmarks > 0"); } let rows = await db.executeCached( ` ${SQL_AUTOFILL_WITH}, - origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, bookmarked, visited) AS ( + origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, n_bookmarks, visited) AS ( SELECT id, prefix, @@ -471,11 +473,11 @@ class ProviderAutofill extends UrlbarProvider { ), host, fixup_url(host), - IFNULL(total(${ORIGIN_FRECENCY_FIELD}) OVER (PARTITION BY fixup_url(host)), 0.0), + total(${ORIGIN_FRECENCY_FIELD}) OVER (PARTITION BY fixup_url(host)), ${ORIGIN_FRECENCY_FIELD}, - MAX(EXISTS( - SELECT 1 FROM moz_places WHERE origin_id = o.id AND foreign_count > 0 - )) OVER (PARTITION BY fixup_url(host)), + total( + (SELECT total(foreign_count) FROM moz_places WHERE origin_id = o.id) + ) OVER (PARTITION BY fixup_url(host)), MAX(EXISTS( SELECT 1 FROM moz_places WHERE origin_id = o.id AND visit_count > 0 )) OVER (PARTITION BY fixup_url(host)) diff --git a/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs deleted file mode 100644 index 5714f11e72..0000000000 --- a/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs +++ /dev/null @@ -1,280 +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 { - UrlbarProvider, - UrlbarUtils, -} from "resource:///modules/UrlbarUtils.sys.mjs"; - -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", - OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.sys.mjs", - loadAndParseOpenSearchEngine: - "resource://gre/modules/OpenSearchLoader.sys.mjs", - UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", - UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", - UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", - UrlbarView: "resource:///modules/UrlbarView.sys.mjs", -}); - -const DYNAMIC_RESULT_TYPE = "contextualSearch"; - -const ENABLED_PREF = "contextualSearch.enabled"; - -const VIEW_TEMPLATE = { - attributes: { - selectable: true, - }, - children: [ - { - name: "no-wrap", - tag: "span", - classList: ["urlbarView-no-wrap", "urlbarView-overflowable"], - children: [ - { - name: "icon", - tag: "img", - classList: ["urlbarView-favicon"], - }, - { - name: "search", - tag: "span", - classList: ["urlbarView-title", "urlbarView-overflowable"], - }, - { - name: "separator", - tag: "span", - classList: ["urlbarView-title-separator"], - }, - { - name: "description", - tag: "span", - }, - ], - }, - ], -}; - -/** - * A provider that returns an option for using the search engine provided - * by the active view if it utilizes OpenSearch. - */ -class ProviderContextualSearch extends UrlbarProvider { - constructor() { - super(); - this.engines = new Map(); - lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); - lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); - } - - /** - * Unique name for the provider, used by the context to filter on providers. - * Not using a unique name will cause the newest registration to win. - * - * @returns {string} - */ - get name() { - return "UrlbarProviderContextualSearch"; - } - - /** - * The type of the provider. - * - * @returns {UrlbarUtils.PROVIDER_TYPE} - */ - get type() { - return UrlbarUtils.PROVIDER_TYPE.PROFILE; - } - - /** - * Whether this provider should be invoked for the given context. - * If this method returns false, the providers manager won't start a query - * with this provider, to save on resources. - * - * @param {UrlbarQueryContext} queryContext The query context object - * @returns {boolean} Whether this provider should be invoked for the search. - */ - isActive(queryContext) { - return ( - queryContext.trimmedSearchString && - !queryContext.searchMode && - lazy.UrlbarPrefs.get(ENABLED_PREF) - ); - } - - /** - * Starts querying. Extended classes should return a Promise resolved when the - * provider is done searching AND returning results. - * - * @param {UrlbarQueryContext} queryContext The query context object - * @param {Function} addCallback Callback invoked by the provider to add a new - * result. A UrlbarResult should be passed to it. - */ - async startQuery(queryContext, addCallback) { - let engine; - const hostname = - queryContext?.currentPage && new URL(queryContext.currentPage).hostname; - - // This happens on about pages, which won't have associated engines - if (!hostname) { - return; - } - - // First check to see if there's a cached search engine for the host. - // If not, check to see if an installed engine matches the current view. - if (this.engines.has(hostname)) { - engine = this.engines.get(hostname); - } else { - // Strip www. to allow for partial matches when looking for an engine. - const [host] = UrlbarUtils.stripPrefixAndTrim(hostname, { - stripWww: true, - }); - engine = ( - await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host, { - matchAllDomainLevels: true, - }) - )[0]; - } - - if (engine) { - let instance = this.queryInstance; - let icon = await engine.getIconURL(); - if (instance != this.queryInstance) { - return; - } - - this.engines.set(hostname, engine); - // Check to see if the engine that was found is the default engine. - // The default engine will often be used to populate the heuristic result, - // and we want to avoid ending up with two nearly identical search results. - let defaultEngine = lazy.UrlbarSearchUtils.getDefaultEngine(); - if (engine.name === defaultEngine?.name) { - return; - } - const [url] = UrlbarUtils.getSearchQueryUrl( - engine, - queryContext.searchString - ); - let result = this.makeResult({ - url, - engine: engine.name, - icon, - input: queryContext.searchString, - shouldNavigate: true, - }); - addCallback(this, result); - return; - } - - // If the current view has engines that haven't been added, return a result - // that will first add an engine, then use it to search. - let window = lazy.BrowserWindowTracker.getTopWindow(); - let engineToAdd = window?.gBrowser.selectedBrowser?.engines?.[0]; - - if (engineToAdd) { - let result = this.makeResult({ - hostname, - url: engineToAdd.uri, - engine: engineToAdd.title, - icon: engineToAdd.icon, - input: queryContext.searchString, - shouldAddEngine: true, - }); - addCallback(this, result); - } - } - - makeResult({ - engine, - icon, - url, - input, - hostname, - shouldNavigate = false, - shouldAddEngine = false, - }) { - let result = new lazy.UrlbarResult( - UrlbarUtils.RESULT_TYPE.DYNAMIC, - UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, - { - engine, - icon, - url, - input, - hostname, - shouldAddEngine, - shouldNavigate, - dynamicType: DYNAMIC_RESULT_TYPE, - } - ); - result.suggestedIndex = -1; - return result; - } - - /** - * This is called when the urlbar view updates the view of one of the results - * of the provider. It should return an object describing the view update. - * See the base UrlbarProvider class for more. - * - * @param {UrlbarResult} result The result whose view will be updated. - * @returns {object} An object describing the view update. - */ - getViewUpdate(result) { - return { - icon: { - attributes: { - src: result.payload.icon || UrlbarUtils.ICON.SEARCH_GLASS, - }, - }, - search: { - textContent: result.payload.input, - attributes: { - title: result.payload.input, - }, - }, - description: { - l10n: { - id: "urlbar-result-action-search-w-engine", - args: { - engine: result.payload.engine, - }, - }, - }, - }; - } - - onLegacyEngagement(state, queryContext, details, controller) { - let { result } = details; - if (result?.providerName == this.name) { - this.#pickResult(result, controller.browserWindow); - } - } - - async #pickResult(result, window) { - // If we have an engine to add, first create a new OpenSearchEngine, then - // get and open a url to execute a search for the term in the url bar. - // In cases where we don't have to create a new engine, navigation is - // handled automatically by providing `shouldNavigate: true` in the result. - if (result.payload.shouldAddEngine) { - let engineData = await lazy.loadAndParseOpenSearchEngine( - Services.io.newURI(result.payload.url) - ); - let newEngine = new lazy.OpenSearchEngine({ engineData }); - newEngine._setIcon(result.payload.icon, false); - this.engines.set(result.payload.hostname, newEngine); - const [url] = UrlbarUtils.getSearchQueryUrl( - newEngine, - result.payload.input - ); - window.gBrowser.fixupAndLoadURIString(url, { - triggeringPrincipal: - Services.scriptSecurityManager.getSystemPrincipal(), - }); - } - } -} - -export var UrlbarProviderContextualSearch = new ProviderContextualSearch(); diff --git a/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs b/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs index 17b6a4c9b0..a259d639cc 100644 --- a/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs @@ -144,15 +144,25 @@ class ProviderInputHistory extends UrlbarProvider { // Don't suggest switching to the current page. continue; } - let result = new lazy.UrlbarResult( - UrlbarUtils.RESULT_TYPE.TAB_SWITCH, - UrlbarUtils.RESULT_SOURCE.TABS, - ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + let payload = lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + { url: [url, UrlbarUtils.HIGHLIGHT.TYPED], title: [resultTitle, UrlbarUtils.HIGHLIGHT.TYPED], icon: UrlbarUtils.getIconForUrl(url), userContextId: row.getResultByName("userContextId") || 0, - }) + } + ); + if (lazy.UrlbarPrefs.get("secondaryActions.featureGate")) { + payload[0].action = { + key: "tabswitch", + l10nId: "urlbar-result-action-switch-tab", + }; + } + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + ...payload ); addCallback(this, result); continue; diff --git a/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs b/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs index c94ebee80a..0787d4c209 100644 --- a/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs @@ -330,17 +330,25 @@ function makeUrlbarResult(tokens, info) { action.params.searchSuggestion.toLocaleLowerCase(), }) ); - case "switchtab": + case "switchtab": { + let payload = lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + userContextId: info.userContextId, + }); + if (lazy.UrlbarPrefs.get("secondaryActions.featureGate")) { + payload[0].action = { + key: "tabswitch", + l10nId: "urlbar-result-action-switch-tab", + }; + } return new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.TAB_SWITCH, UrlbarUtils.RESULT_SOURCE.TABS, - ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { - url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], - title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], - icon: info.icon, - userContextId: info.userContextId, - }) + ...payload ); + } case "visiturl": return new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.URL, diff --git a/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs deleted file mode 100644 index 29370cbaaf..0000000000 --- a/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs +++ /dev/null @@ -1,357 +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 { - UrlbarProvider, - UrlbarUtils, -} from "resource:///modules/UrlbarUtils.sys.mjs"; - -const lazy = {}; -ChromeUtils.defineESModuleGetters(lazy, { - QuickActionsLoaderDefault: - "resource:///modules/QuickActionsLoaderDefault.sys.mjs", - UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", - UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", -}); - -// These prefs are relative to the `browser.urlbar` branch. -const ENABLED_PREF = "quickactions.enabled"; -const SUGGEST_PREF = "suggest.quickactions"; -const MATCH_IN_PHRASE_PREF = "quickactions.matchInPhrase"; -const MIN_SEARCH_PREF = "quickactions.minimumSearchString"; -const DYNAMIC_TYPE_NAME = "quickactions"; - -// When the urlbar is first focused and no search term has been -// entered we show a limited number of results. -const ACTIONS_SHOWN_FOCUS = 4; - -// Default icon shown for actions if no custom one is provided. -const DEFAULT_ICON = "chrome://global/skin/icons/settings.svg"; - -// The suggestion index of the actions row within the urlbar results. -const SUGGESTED_INDEX = 1; - -/** - * A provider that returns a suggested url to the user based on what - * they have currently typed so they can navigate directly. - */ -class ProviderQuickActions extends UrlbarProvider { - constructor() { - super(); - lazy.UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME); - } - - /** - * Returns the name of this provider. - * - * @returns {string} the name of this provider. - */ - get name() { - return DYNAMIC_TYPE_NAME; - } - - /** - * The type of the provider. - * - * @returns {UrlbarUtils.PROVIDER_TYPE} - */ - get type() { - return UrlbarUtils.PROVIDER_TYPE.PROFILE; - } - - getPriority(context) { - if (!context.searchString) { - return 1; - } - return 0; - } - - /** - * Whether this provider should be invoked for the given context. - * If this method returns false, the providers manager won't start a query - * with this provider, to save on resources. - * - * @param {UrlbarQueryContext} queryContext The query context object - * @returns {boolean} Whether this provider should be invoked for the search. - */ - isActive(queryContext) { - return ( - queryContext.trimmedSearchString.length < 50 && - lazy.UrlbarPrefs.get(ENABLED_PREF) && - ((lazy.UrlbarPrefs.get(SUGGEST_PREF) && !queryContext.searchMode) || - queryContext.searchMode?.source == UrlbarUtils.RESULT_SOURCE.ACTIONS) - ); - } - - /** - * Starts querying. Extended classes should return a Promise resolved when the - * provider is done searching AND returning results. - * - * @param {UrlbarQueryContext} queryContext The query context object - * @param {Function} addCallback Callback invoked by the provider to add a new - * result. A UrlbarResult should be passed to it. - * @returns {Promise} - */ - async startQuery(queryContext, addCallback) { - await lazy.QuickActionsLoaderDefault.ensureLoaded(); - let input = queryContext.trimmedLowerCaseSearchString; - - if ( - !queryContext.searchMode && - input.length < lazy.UrlbarPrefs.get(MIN_SEARCH_PREF) - ) { - return; - } - - let results = [...(this.#prefixes.get(input) ?? [])]; - - if (lazy.UrlbarPrefs.get(MATCH_IN_PHRASE_PREF)) { - for (let [keyword, key] of this.#keywords) { - if (input.includes(keyword)) { - results.push(key); - } - } - } - // Ensure results are unique. - results = [...new Set(results)]; - - // Remove invisible actions. - results = results.filter(key => { - const action = this.#actions.get(key); - return !action.isVisible || action.isVisible(); - }); - - if (!results?.length) { - return; - } - - // If all actions are inactive, don't show anything. - if ( - results.every(key => { - const action = this.#actions.get(key); - return action.isActive && !action.isActive(); - }) - ) { - return; - } - - // If we are in the Actions searchMode then we want to show all the actions - // but not when we are in the normal url mode on first focus. - if ( - results.length > ACTIONS_SHOWN_FOCUS && - !input && - !queryContext.searchMode - ) { - results.length = ACTIONS_SHOWN_FOCUS; - } - - const result = new lazy.UrlbarResult( - UrlbarUtils.RESULT_TYPE.DYNAMIC, - UrlbarUtils.RESULT_SOURCE.ACTIONS, - { - results: results.map(key => ({ key })), - dynamicType: DYNAMIC_TYPE_NAME, - inputLength: input.length, - inQuickActionsSearchMode: - queryContext.searchMode?.source == UrlbarUtils.RESULT_SOURCE.ACTIONS, - } - ); - result.suggestedIndex = SUGGESTED_INDEX; - addCallback(this, result); - this.#resultFromLastQuery = result; - } - - getViewTemplate(result) { - return { - children: [ - { - name: "buttons", - tag: "div", - attributes: { - "data-is-quickactions-searchmode": - result.payload.inQuickActionsSearchMode, - }, - children: result.payload.results.map(({ key }, i) => { - let action = this.#actions.get(key); - let inActive = "isActive" in action && !action.isActive(); - return { - name: `button-${i}`, - tag: "span", - attributes: { - "data-key": key, - "data-input-length": result.payload.inputLength, - class: "urlbarView-quickaction-button", - role: inActive ? "" : "button", - disabled: inActive, - }, - children: [ - { - name: `icon-${i}`, - tag: "div", - attributes: { class: "urlbarView-favicon" }, - children: [ - { - name: `image-${i}`, - tag: "img", - attributes: { - class: "urlbarView-favicon-img", - src: action.icon || DEFAULT_ICON, - }, - }, - ], - }, - { - name: `label-${i}`, - tag: "span", - attributes: { class: "urlbarView-label" }, - }, - ], - }; - }), - }, - ], - }; - } - - getViewUpdate(result) { - let viewUpdate = {}; - result.payload.results.forEach(({ key }, i) => { - let action = this.#actions.get(key); - viewUpdate[`label-${i}`] = { - l10n: { id: action.label, cacheable: true }, - }; - }); - return viewUpdate; - } - - #pickResult(result, itemPicked) { - let { key, inputLength } = itemPicked.dataset; - // We clamp the input length to limit the number of keys to - // the number of actions * 10. - inputLength = Math.min(inputLength, 10); - Services.telemetry.keyedScalarAdd( - `quickaction.picked`, - `${key}-${inputLength}`, - 1 - ); - let options = this.#actions.get(itemPicked.dataset.key).onPick() ?? {}; - if (options.focusContent) { - itemPicked.ownerGlobal.gBrowser.selectedBrowser.focus(); - } - } - - 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; - } - - if (state == "engagement" && queryContext) { - // Get the result that's visible in the view. `details.result` is the - // engaged result, if any; if it's from this provider, then that's the - // visible result. Otherwise fall back to #getVisibleResultFromLastQuery. - let { result } = details; - if (result?.providerName != this.name) { - result = this.#getVisibleResultFromLastQuery(controller.view); - } - - result?.payload.results.forEach(({ key }) => { - Services.telemetry.keyedScalarAdd( - `quickaction.impression`, - `${key}-${queryContext.trimmedSearchString.length}`, - 1 - ); - }); - } - - // Handle picks. - if (details.result?.providerName == this.name) { - this.#pickResult(details.result, details.element); - } - - this.#resultFromLastQuery = null; - } - - /** - * Adds a new QuickAction. - * - * @param {string} key A key to identify this action. - * @param {string} definition An object that describes the action. - */ - addAction(key, definition) { - this.#actions.set(key, definition); - definition.commands.forEach(cmd => this.#keywords.set(cmd, key)); - this.#loopOverPrefixes(definition.commands, prefix => { - let result = this.#prefixes.get(prefix); - if (result) { - if (!result.includes(key)) { - result.push(key); - } - } else { - result = [key]; - } - this.#prefixes.set(prefix, result); - }); - } - - /** - * Removes an action. - * - * @param {string} key A key to identify this action. - */ - removeAction(key) { - let definition = this.#actions.get(key); - this.#actions.delete(key); - definition.commands.forEach(cmd => this.#keywords.delete(cmd)); - this.#loopOverPrefixes(definition.commands, prefix => { - let result = this.#prefixes.get(prefix); - if (result) { - result = result.filter(val => val != key); - } - this.#prefixes.set(prefix, result); - }); - } - - // A map from keywords to an action. - #keywords = new Map(); - - // A map of all prefixes to an array of actions. - #prefixes = new Map(); - - // The actions that have been added. - #actions = new Map(); - - // The result we added during the most recent query. - #resultFromLastQuery = null; - - #loopOverPrefixes(commands, fun) { - for (const command of commands) { - // Loop over all the prefixes of the word, ie - // "", "w", "wo", "wor", stopping just before the full - // word itself which will be matched by the whole - // phrase matching. - for (let i = 1; i <= command.length; i++) { - let prefix = command.substring(0, command.length - i); - fun(prefix); - } - } - } - - #getVisibleResultFromLastQuery(view) { - let result = this.#resultFromLastQuery; - - if ( - result?.rowIndex >= 0 && - view?.visibleResults?.[result.rowIndex] == result - ) { - // The result was visible. - return result; - } - - // Find a visible result. - return view?.visibleResults?.find(r => r.providerName == this.name); - } -} - -export var UrlbarProviderQuickActions = new ProviderQuickActions(); diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs index fbc8cc8c3f..10244e06b5 100644 --- a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs @@ -45,7 +45,6 @@ const TELEMETRY_SCALARS = { CLICK_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.click_nav_superceded`, CLICK_NONSPONSORED: `${TELEMETRY_PREFIX}.click_nonsponsored`, CLICK_SPONSORED: `${TELEMETRY_PREFIX}.click_sponsored`, - HELP_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.help_dynamic_wikipedia`, HELP_NONSPONSORED: `${TELEMETRY_PREFIX}.help_nonsponsored`, HELP_SPONSORED: `${TELEMETRY_PREFIX}.help_sponsored`, IMPRESSION_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.impression_dynamic_wikipedia`, @@ -413,14 +412,11 @@ class ProviderQuickSuggest extends UrlbarProvider { let payload = { url: suggestion.url, isSponsored: suggestion.is_sponsored, - 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, }; if (suggestion.full_keyword) { @@ -592,9 +588,6 @@ class ProviderQuickSuggest extends UrlbarProvider { scalars.push(TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA); } else { switch (resultSelType) { - case "help": - scalars.push(TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA); - break; case "dismiss": scalars.push(TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA); break; diff --git a/browser/components/urlbar/UrlbarProviderWeather.sys.mjs b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs index 8e9b6b8f3e..6dc38e7aed 100644 --- a/browser/components/urlbar/UrlbarProviderWeather.sys.mjs +++ b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs @@ -20,7 +20,6 @@ const TELEMETRY_PREFIX = "contextual.services.quicksuggest"; const TELEMETRY_SCALARS = { BLOCK: `${TELEMETRY_PREFIX}.block_weather`, CLICK: `${TELEMETRY_PREFIX}.click_weather`, - HELP: `${TELEMETRY_PREFIX}.help_weather`, IMPRESSION: `${TELEMETRY_PREFIX}.impression_weather`, }; @@ -233,7 +232,6 @@ class ProviderWeather extends UrlbarProvider { * * - "": The user didn't pick the row or any part of it * - "weather": The user picked the main part of the row - * - "help": The user picked the help button * - "dismiss": The user dismissed the result * * An empty string means the user picked some other row to end the @@ -265,10 +263,6 @@ class ProviderWeather extends UrlbarProvider { clickScalars.push(TELEMETRY_SCALARS.CLICK); eventObject = "click"; break; - case "help": - clickScalars.push(TELEMETRY_SCALARS.HELP); - eventObject = "help"; - break; case "dismiss": clickScalars.push(TELEMETRY_SCALARS.BLOCK); eventObject = "block"; diff --git a/browser/components/urlbar/UrlbarProvidersManager.sys.mjs b/browser/components/urlbar/UrlbarProvidersManager.sys.mjs index ac70e03e1b..3fd9b0caf3 100644 --- a/browser/components/urlbar/UrlbarProvidersManager.sys.mjs +++ b/browser/components/urlbar/UrlbarProvidersManager.sys.mjs @@ -40,8 +40,6 @@ var localProviderModules = { "resource:///modules/UrlbarProviderCalculator.sys.mjs", UrlbarProviderClipboard: "resource:///modules/UrlbarProviderClipboard.sys.mjs", - UrlbarProviderContextualSearch: - "resource:///modules/UrlbarProviderContextualSearch.sys.mjs", UrlbarProviderHeuristicFallback: "resource:///modules/UrlbarProviderHeuristicFallback.sys.mjs", UrlbarProviderHistoryUrlHeuristic: @@ -54,8 +52,6 @@ var localProviderModules = { UrlbarProviderPlaces: "resource:///modules/UrlbarProviderPlaces.sys.mjs", UrlbarProviderPrivateSearch: "resource:///modules/UrlbarProviderPrivateSearch.sys.mjs", - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", UrlbarProviderQuickSuggest: "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", UrlbarProviderQuickSuggestContextualOptIn: @@ -84,6 +80,14 @@ var localMuxerModules = { "resource:///modules/UrlbarMuxerUnifiedComplete.sys.mjs", }; +import { ActionsProviderQuickActions } from "resource:///modules/ActionsProviderQuickActions.sys.mjs"; +import { ActionsProviderContextualSearch } from "resource:///modules/ActionsProviderContextualSearch.sys.mjs"; + +let globalActionsProviders = [ + ActionsProviderContextualSearch, + ActionsProviderQuickActions, +]; + const DEFAULT_MUXER = "UnifiedComplete"; /** @@ -179,6 +183,17 @@ class ProvidersManager { } /** + * Returns the provider with the given name. + * + * @param {string} name + * The provider name. + * @returns {UrlbarProvider} The provider. + */ + getActionProvider(name) { + return globalActionsProviders.find(p => p.name == name); + } + + /** * Registers a muxer object with the manager. * * @param {object} muxer @@ -284,6 +299,12 @@ class ProvidersManager { // history and bookmarks even if search engines are not available. } + // All current global actions are currently memory lookups so it is safe to + // wait on them. + this.#globalAction = lazy.UrlbarPrefs.get("secondaryActions.featureGate") + ? await this.pickGlobalAction(queryContext, controller) + : null; + if (query.canceled) { return; } @@ -357,6 +378,25 @@ class ProvidersManager { ); } } + + #globalAction = null; + + async pickGlobalAction(queryContext, controller) { + for (let provider of globalActionsProviders) { + if (provider.isActive(queryContext)) { + let action = await provider.queryAction(queryContext, controller); + if (action) { + action.providerName = provider.name; + return action; + } + } + } + return null; + } + + getGlobalAction() { + return this.#globalAction; + } } export var UrlbarProvidersManager = new ProvidersManager(); diff --git a/browser/components/urlbar/UrlbarTokenizer.sys.mjs b/browser/components/urlbar/UrlbarTokenizer.sys.mjs index c0b3a9c069..ee565ec1c9 100644 --- a/browser/components/urlbar/UrlbarTokenizer.sys.mjs +++ b/browser/components/urlbar/UrlbarTokenizer.sys.mjs @@ -246,7 +246,7 @@ export var UrlbarTokenizer = { queryContext.tokens = []; return queryContext; } - let unfiltered = splitString(queryContext.searchString); + let unfiltered = splitString(queryContext); let tokens = filterTokens(unfiltered); queryContext.tokens = tokens; return queryContext; @@ -276,13 +276,17 @@ const CHAR_TO_TYPE_MAP = new Map( ); /** - * Given a search string, splits it into string tokens. + * Given a queryContext object, splits its searchString into string tokens. * - * @param {string} searchString - * The search string to split + * @param {UrlbarQueryContext} queryContext + * The query context object to tokenize. + * @param {string} queryContext.searchString + * The search string to split. + * @param {object} queryContext.searchMode + * A search mode object. * @returns {Array} An array of string tokens. */ -function splitString(searchString) { +function splitString({ searchString, searchMode }) { // The first step is splitting on unicode whitespaces. We ignore whitespaces // if the search string starts with "data:", to better support Web developers // and compatiblity with other browsers. @@ -327,7 +331,8 @@ function splitString(searchString) { // allow for a typed question to yield only search results. if ( CHAR_TO_TYPE_MAP.has(firstToken[0]) && - !UrlbarTokenizer.REGEXP_PERCENT_ENCODED_START.test(firstToken) + !UrlbarTokenizer.REGEXP_PERCENT_ENCODED_START.test(firstToken) && + !searchMode ) { tokens[0] = firstToken.substring(1); tokens.splice(0, 0, firstToken[0]); diff --git a/browser/components/urlbar/UrlbarUtils.sys.mjs b/browser/components/urlbar/UrlbarUtils.sys.mjs index 9fca8426a3..096d1c8f2d 100644 --- a/browser/components/urlbar/UrlbarUtils.sys.mjs +++ b/browser/components/urlbar/UrlbarUtils.sys.mjs @@ -113,8 +113,7 @@ export var UrlbarUtils = { TABS: 4, OTHER_LOCAL: 5, OTHER_NETWORK: 6, - ACTIONS: 7, - ADDON: 8, + ADDON: 7, }, // This defines icon locations that are commonly used in the UI. @@ -228,13 +227,6 @@ export var UrlbarUtils = { pref: "shortcuts.history", telemetryLabel: "history", }, - { - source: UrlbarUtils.RESULT_SOURCE.ACTIONS, - restrict: lazy.UrlbarTokenizer.RESTRICT.ACTION, - icon: "chrome://browser/skin/quickactions.svg", - pref: "shortcuts.quickactions", - telemetryLabel: "actions", - }, ]; }, @@ -1279,8 +1271,6 @@ export var UrlbarUtils = { if (result.providerName == "TabToSearch") { // This is the onboarding result. return "tabtosearch"; - } else if (result.providerName == "quickactions") { - return "quickaction"; } else if (result.providerName == "Weather") { return "weather"; } @@ -1435,14 +1425,10 @@ export var UrlbarUtils = { switch (result.providerName) { case "calculator": return "calc"; - case "quickactions": - return "action"; case "TabToSearch": return "tab_to_search"; case "UnitConversion": return "unit"; - case "UrlbarProviderContextualSearch": - return "site_specific_contextual_search"; case "UrlbarProviderQuickSuggest": return this._getQuickSuggestTelemetryType(result); case "UrlbarProviderQuickSuggestContextualOptIn": @@ -1535,28 +1521,6 @@ export var UrlbarUtils = { return "unknown"; }, - /** - * Extracts a subtype for search engagement telemetry from a result and the picked element. - * - * @param {UrlbarResult} result The result to analyze. - * @param {DOMElement} element The picked view element. Nullable. - * @returns {string} Subtype as string. - */ - searchEngagementTelemetrySubtype(result, element) { - if (!result) { - return ""; - } - - if ( - result.providerName === "quickactions" && - element?.classList.contains("urlbarView-quickaction-button") - ) { - return element.dataset.key; - } - - return ""; - }, - _getQuickSuggestTelemetryType(result) { if (result.payload.telemetryType == "weather") { // Return "weather" without the usual source prefix for consistency with @@ -1641,6 +1605,17 @@ UrlbarUtils.RESULT_PAYLOAD_SCHEMA = { type: "object", required: ["url"], properties: { + action: { + type: "object", + properties: { + l10nId: { + type: "string", + }, + key: { + type: "string", + }, + }, + }, displayUrl: { type: "string", }, diff --git a/browser/components/urlbar/UrlbarValueFormatter.sys.mjs b/browser/components/urlbar/UrlbarValueFormatter.sys.mjs index b27bede750..2fa3d0137d 100644 --- a/browser/components/urlbar/UrlbarValueFormatter.sys.mjs +++ b/browser/components/urlbar/UrlbarValueFormatter.sys.mjs @@ -146,12 +146,14 @@ export class UrlbarValueFormatter { // we can skip most of this. if ( browser._urlMetaData && - browser._urlMetaData.inputValue == this.urlbarInput.untrimmedValue + browser._urlMetaData.inputValue == inputValue && + browser._urlMetaData.untrimmedValue == this.urlbarInput.untrimmedValue ) { return browser._urlMetaData.data; } browser._urlMetaData = { - inputValue: this.urlbarInput.untrimmedValue, + inputValue, + untrimmedValue: this.urlbarInput.untrimmedValue, data: null, }; diff --git a/browser/components/urlbar/UrlbarView.sys.mjs b/browser/components/urlbar/UrlbarView.sys.mjs index 3d6ea46781..c5ea040f1f 100644 --- a/browser/components/urlbar/UrlbarView.sys.mjs +++ b/browser/components/urlbar/UrlbarView.sys.mjs @@ -1635,6 +1635,38 @@ export class UrlbarView { item.appendChild(button); } + #createSecondaryAction(action, global = false) { + let actionContainer = this.#createElement("div"); + actionContainer.classList.add("urlbarView-actions-container"); + + let button = this.#createElement("span"); + button.classList.add("urlbarView-action-btn"); + if (global) { + button.classList.add("urlbarView-global-action-btn"); + } + button.setAttribute("role", "button"); + if (action.icon) { + let icon = this.#createElement("img"); + icon.src = action.icon; + button.appendChild(icon); + } + for (let key in action.dataset ?? {}) { + button.dataset[key] = action.dataset[key]; + } + button.dataset.action = action.key; + button.dataset.providerName = action.providerName; + + let label = this.#createElement("span"); + if (action.l10nId) { + this.#setElementL10n(label, { id: action.l10nId, args: action.l10nArgs }); + } else { + this.document.l10n.setAttributes(label, action.label, action.l10nArgs); + } + button.appendChild(label); + actionContainer.appendChild(button); + return actionContainer; + } + // eslint-disable-next-line complexity #updateRow(item, result) { let oldResult = item.result; @@ -1707,6 +1739,26 @@ export class UrlbarView { } item._content.id = item.id + "-inner"; + let isFirstChild = item === this.#rows.children[0]; + let secAction = + result.heuristic || isFirstChild + ? lazy.UrlbarProvidersManager.getGlobalAction() + : result.payload.action; + let container = item.querySelector(".urlbarView-actions-container"); + if (secAction && !container) { + item.appendChild(this.#createSecondaryAction(secAction, isFirstChild)); + } else if ( + secAction && + secAction.key != container.firstChild.dataset.action + ) { + item.replaceChild( + this.#createSecondaryAction(secAction, isFirstChild), + container + ); + } else if (!secAction && container) { + item.removeChild(container); + } + item.removeAttribute("feedback-acknowledgment"); if ( @@ -1800,6 +1852,10 @@ export class UrlbarView { let isRowSelectable = true; switch (result.type) { case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + // Hide chichlet when showing secondaryActions. + if (lazy.UrlbarPrefs.get("secondaryActions.featureGate")) { + break; + } actionSetter = () => { this.#setSwitchTabActionChiclet(result, action); }; diff --git a/browser/components/urlbar/docs/firefox-suggest-telemetry.rst b/browser/components/urlbar/docs/firefox-suggest-telemetry.rst index 8d9c7c20ff..e3d37605e1 100644 --- a/browser/components/urlbar/docs/firefox-suggest-telemetry.rst +++ b/browser/components/urlbar/docs/firefox-suggest-telemetry.rst @@ -501,7 +501,11 @@ Changelog Firefox 109.0 Introduced. [Bug 1800993_] + Firefox 127.0 + Removed. [Bug 1891602_] + .. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 +.. _1891602: https://bugzilla.mozilla.org/show_bug.cgi?id=1891602 contextual.services.quicksuggest.help_nonsponsored ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -579,7 +583,11 @@ Changelog Firefox 110.0 Introduced. [Bug 1804536_] + Firefox 127.0 + Removed. [Bug 1891602_] + .. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 +.. _1891602: https://bugzilla.mozilla.org/show_bug.cgi?id=1891602 contextual.services.quicksuggest.impression ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/browser/components/urlbar/metrics.yaml b/browser/components/urlbar/metrics.yaml index 173ee08a10..5140391e4f 100644 --- a/browser/components/urlbar/metrics.yaml +++ b/browser/components/urlbar/metrics.yaml @@ -487,11 +487,13 @@ urlbar: - https://bugzilla.mozilla.org/show_bug.cgi?id=1852058 data_reviews: - https://bugzilla.mozilla.org/show_bug.cgi?id=1852058#c2 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1866204#c8 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1892377#c2 data_sensitivity: - interaction notification_emails: - fx-search-telemetry@mozilla.com - expires: 128 + expires: 132 pref_max_results: lifetime: application diff --git a/browser/components/urlbar/moz.build b/browser/components/urlbar/moz.build index e35ea11655..4b91bef331 100644 --- a/browser/components/urlbar/moz.build +++ b/browser/components/urlbar/moz.build @@ -12,6 +12,9 @@ DIRS += [ ] EXTRA_JS_MODULES += [ + "ActionsProvider.sys.mjs", + "ActionsProviderContextualSearch.sys.mjs", + "ActionsProviderQuickActions.sys.mjs", "MerinoClient.sys.mjs", "QuickActionsLoaderDefault.sys.mjs", "QuickSuggest.sys.mjs", @@ -26,7 +29,6 @@ EXTRA_JS_MODULES += [ "UrlbarProviderBookmarkKeywords.sys.mjs", "UrlbarProviderCalculator.sys.mjs", "UrlbarProviderClipboard.sys.mjs", - "UrlbarProviderContextualSearch.sys.mjs", "UrlbarProviderHeuristicFallback.sys.mjs", "UrlbarProviderHistoryUrlHeuristic.sys.mjs", "UrlbarProviderInputHistory.sys.mjs", @@ -35,7 +37,6 @@ EXTRA_JS_MODULES += [ "UrlbarProviderOpenTabs.sys.mjs", "UrlbarProviderPlaces.sys.mjs", "UrlbarProviderPrivateSearch.sys.mjs", - "UrlbarProviderQuickActions.sys.mjs", "UrlbarProviderQuickSuggest.sys.mjs", "UrlbarProviderQuickSuggestContextualOptIn.sys.mjs", "UrlbarProviderRecentSearches.sys.mjs", diff --git a/browser/components/urlbar/private/SuggestBackendRust.sys.mjs b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs index 3993149757..120e7b7d0c 100644 --- a/browser/components/urlbar/private/SuggestBackendRust.sys.mjs +++ b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs @@ -289,7 +289,10 @@ export class SuggestBackendRust extends BaseFeature { if (instance != this.#ingestInstance) { return; } - await (this.#ingestPromise = this.#ingestHelper()); + this.#ingestPromise = new Promise(resolve => { + ChromeUtils.idleDispatch(() => this.#ingestHelper().finally(resolve)); + }); + await this.#ingestPromise; } async #ingestHelper() { diff --git a/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs index f576f4ca19..793af24b41 100644 --- a/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs +++ b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs @@ -1043,9 +1043,11 @@ export var UrlbarTestUtils = { * Removes the scheme from an url according to user prefs. * * @param {string} url - * The url that is supposed to be sanitizied. - * @param {{removeSingleTrailingSlash: (boolean)}} options - * removeSingleTrailingSlash: Remove trailing slash, when trimming enabled. + * The url that is supposed to be trimmed. + * @param {object} [options] + * Options for the trimming. + * @param {boolean} [options.removeSingleTrailingSlash] + * Remove trailing slash, when trimming enabled. * @returns {string} * The sanitized URL. */ @@ -1060,15 +1062,13 @@ export var UrlbarTestUtils = { lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(sanitizedURL); } + // Also remove emphasis markers if present. if (lazy.UrlbarPrefs.get("trimHttps")) { - sanitizedURL = sanitizedURL.replace("https://", ""); + sanitizedURL = sanitizedURL.replace(/^<?https:\/\/>?/, ""); } else { - sanitizedURL = sanitizedURL.replace("http://", ""); + sanitizedURL = sanitizedURL.replace(/^<?http:\/\/>?/, ""); } - // Remove empty emphasis markers in case the protocol was trimmed. - sanitizedURL = sanitizedURL.replace("<>", ""); - return sanitizedURL; }, diff --git a/browser/components/urlbar/tests/browser/browser.toml b/browser/components/urlbar/tests/browser/browser.toml index 44b964e5ca..38046aa26b 100644 --- a/browser/components/urlbar/tests/browser/browser.toml +++ b/browser/components/urlbar/tests/browser/browser.toml @@ -68,6 +68,8 @@ skip-if = ["apple_catalina && debug"] # Bug 1773790 ["browser_UrlbarInput_trimURLs.js"] https_first_disabled = true +["browser_UrlbarInput_untrimOnUserInteraction.js"] + ["browser_aboutHomeLoading.js"] skip-if = [ "tsan", # Intermittently times out, see 1622698 (frequent on TSan). @@ -365,6 +367,8 @@ support-files = [ ["browser_quickactions.js"] +["browser_quickactions_commands.js"] + ["browser_quickactions_devtools.js"] ["browser_quickactions_screenshot.js"] @@ -498,6 +502,8 @@ support-files = ["search-engines", "../../../search/test/browser/trendingSuggest ["browser_search_history_from_history_panel.js"] +["browser_secondaryActions.js"] + ["browser_selectStaleResults.js"] support-files = [ "searchSuggestionEngineSlow.xml", @@ -645,9 +651,6 @@ tags = "search-telemetry" https_first_disabled = true tags = "search-telemetry" -["browser_urlbar_telemetry_quickactions.js"] -tags = "search-telemetry" - ["browser_urlbar_telemetry_remotetab.js"] tags = "search-telemetry" diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_untrimOnUserInteraction.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_untrimOnUserInteraction.js new file mode 100644 index 0000000000..a6714df360 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_untrimOnUserInteraction.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let tests = [ + { + description: "Test single click doesn't untrim", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + execute() { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, 0, "Selection start is 0."); + Assert.equal( + gURLBar.selectionEnd, + gURLBar.value.length, + "Selection end is at and of text." + ); + }, + shouldUntrim: false, + }, + { + description: "Test CTRL+L doesn't untrim", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + execute() { + EventUtils.synthesizeKey("l", { accelKey: true }); + Assert.equal(gURLBar.selectionStart, 0, "Selection start is 0."); + Assert.equal( + gURLBar.selectionEnd, + gURLBar.value.length, + "Selection end is at and of text." + ); + }, + shouldUntrim: false, + }, + { + description: "Test drag selection untrims", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + execute() { + selectWithMouseDrag(100, 200); + Assert.greater(gURLBar.selectionStart, 0, "Selection start is positive."); + Assert.greater( + gURLBar.selectionEnd, + gURLBar.selectionStart, + "Selection is not empty." + ); + }, + shouldUntrim: true, + }, + { + description: "Test double click selection untrims", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + execute() { + selectWithDoubleClick(200); + Assert.greater(gURLBar.selectionStart, 0, "Selection start is positive."); + Assert.greater( + gURLBar.selectionEnd, + gURLBar.selectionStart, + "Selection is not empty." + ); + }, + shouldUntrim: true, + }, + { + description: "Test click, LEFT untrims", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + execute() { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + }, + shouldUntrim: true, + }, + { + description: "Test CTRL+L, HOME untrims", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + execute() { + EventUtils.synthesizeKey("l", { accelKey: true }); + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("KEY_ArrowLeft", { metaKey: true }); + } else { + EventUtils.synthesizeKey("KEY_Home"); + } + }, + shouldUntrim: true, + }, + { + description: "Test SHIFT+LEFT untrims", + untrimmedValue: BrowserUIUtils.trimURLProtocol + "www.example.com/", + async execute() { + EventUtils.synthesizeKey("l", { accelKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }); + Assert.equal(gURLBar.selectionStart, 0, "Selection start is 0."); + Assert.less( + gURLBar.selectionEnd, + gURLBar.value.length, + "Selection skips last characters." + ); + }, + shouldUntrim: true, + }, +]; + +add_task(async function test_untrim() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.untrimOnUserInteraction.featureGate", true]], + }); + + for (let test of tests) { + info(test.description); + let trimmedValue = UrlbarTestUtils.trimURL(test.untrimmedValue); + gURLBar._setValue(test.untrimmedValue, { + allowTrim: true, + valueIsTyped: false, + }); + gURLBar.blur(); + Assert.equal(gURLBar.value, trimmedValue, "Value has been trimmed"); + await test.execute(); + Assert.equal( + gURLBar.value, + test.shouldUntrim ? test.untrimmedValue : trimmedValue, + "Value has been untrimmed" + ); + gURLBar.handleRevert(); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js b/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js index 4fa60f6bf3..220634eb2c 100644 --- a/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js @@ -51,6 +51,7 @@ add_task(async function () { info("Press backspace"); EventUtils.synthesizeKey("KEY_Backspace"); + info("Backspaced value is " + gURLBar.value); await UrlbarTestUtils.promiseSearchComplete(window); let editedValue = gURLBar.value; diff --git a/browser/components/urlbar/tests/browser/browser_contextualsearch.js b/browser/components/urlbar/tests/browser/browser_contextualsearch.js index 60e489a542..449d1864c2 100644 --- a/browser/components/urlbar/tests/browser/browser_contextualsearch.js +++ b/browser/components/urlbar/tests/browser/browser_contextualsearch.js @@ -3,22 +3,54 @@ "use strict"; -const { UrlbarProviderContextualSearch } = ChromeUtils.importESModule( - "resource:///modules/UrlbarProviderContextualSearch.sys.mjs" +const { ActionsProviderContextualSearch } = ChromeUtils.importESModule( + "resource:///modules/ActionsProviderContextualSearch.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" ); add_setup(async function setup() { await SpecialPowers.pushPrefEnv({ - set: [["browser.urlbar.contextualSearch.enabled", true]], + set: [ + ["browser.urlbar.contextualSearch.enabled", true], + ["browser.urlbar.secondaryActions.featureGate", true], + ], }); -}); -add_task(async function test_selectContextualSearchResult_already_installed() { - await SearchTestUtils.installSearchExtension({ + let ext = await SearchTestUtils.installSearchExtension({ name: "Contextual", search_url: "https://example.com/browser", }); + await AddonTestUtils.waitForSearchProviderStartup(ext); +}); + +add_task(async function test_no_engine() { + const ENGINE_TEST_URL = "https://example.org/"; + let onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + ENGINE_TEST_URL + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + ENGINE_TEST_URL + ); + await onLoaded; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) > 0, + "At least one result is shown" + ); +}); + +add_task(async function test_selectContextualSearchResult_already_installed() { const ENGINE_TEST_URL = "https://example.com/"; let onLoaded = BrowserTestUtils.browserLoaded( gBrowser.selectedBrowser, @@ -44,25 +76,15 @@ add_task(async function test_selectContextualSearchResult_already_installed() { window, value: query, }); - const resultIndex = UrlbarTestUtils.getResultCount(window) - 1; - const result = await UrlbarTestUtils.getDetailsOfResultAt( - window, - resultIndex - ); - - is( - result.dynamicType, - "contextualSearch", - "Second last result is a contextual search result" - ); info("Focus and select the contextual search result"); - UrlbarTestUtils.setSelectedRowIndex(window, resultIndex); let onLoad = BrowserTestUtils.browserLoaded( gBrowser.selectedBrowser, false, expectedUrl ); + + EventUtils.synthesizeKey("KEY_Tab"); EventUtils.synthesizeKey("KEY_Enter"); await onLoad; @@ -95,25 +117,14 @@ add_task(async function test_selectContextualSearchResult_not_installed() { window, value: query, }); - const resultIndex = UrlbarTestUtils.getResultCount(window) - 1; - const result = await UrlbarTestUtils.getDetailsOfResultAt( - window, - resultIndex - ); - - Assert.equal( - result.dynamicType, - "contextualSearch", - "Second last result is a contextual search result" - ); info("Focus and select the contextual search result"); - UrlbarTestUtils.setSelectedRowIndex(window, resultIndex); let onLoad = BrowserTestUtils.browserLoaded( gBrowser.selectedBrowser, false, EXPECTED_URL ); + EventUtils.synthesizeKey("KEY_Tab"); EventUtils.synthesizeKey("KEY_Enter"); await onLoad; @@ -122,4 +133,6 @@ add_task(async function test_selectContextualSearchResult_not_installed() { EXPECTED_URL, "Selecting the contextual search result opens the search URL" ); + + ActionsProviderContextualSearch.resetForTesting(); }); diff --git a/browser/components/urlbar/tests/browser/browser_decode.js b/browser/components/urlbar/tests/browser/browser_decode.js index 577d39b587..ee7831eea6 100644 --- a/browser/components/urlbar/tests/browser/browser_decode.js +++ b/browser/components/urlbar/tests/browser/browser_decode.js @@ -72,7 +72,7 @@ add_task(async function actionURILosslessDecode() { Assert.equal( gURLBar.value, - UrlbarTestUtils.trimURL(urlNoScheme), + urlNoScheme, "The string displayed in the textbox should not be escaped" ); diff --git a/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js b/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js index 6ad6ce43e6..abe12846ab 100644 --- a/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js +++ b/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js @@ -3,6 +3,10 @@ "use strict"; +add_setup(async function setup() { + registerCleanupFunction(PlacesUtils.history.clear); +}); + /** * Verify user typed text remains in the URL bar when tab switching, even when * loads fail. @@ -78,94 +82,94 @@ add_task(async function invalidURL() { * Test the urlbar status of text selection and focusing by tab switching. */ add_task(async function selectAndFocus() { - // Create a tab with normal web page. Use a test-url that uses a protocol that - // is not trimmed. - const webpageTabURL = - UrlbarTestUtils.getTrimmedProtocolWithSlashes() == "https://" - ? "http://example.com" - : "https://example.com"; - const webpageTab = await BrowserTestUtils.openNewForegroundTab({ - gBrowser, - url: webpageTabURL, - }); + // Test both protocols to ensure we're testing any trimming case. + for (let protocol of ["http://", "https://"]) { + const webpageTabURL = protocol + "example.com"; + const webpageTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: webpageTabURL, + }); - // Create a tab with userTypedValue. - const userTypedTabText = "test"; - const userTypedTab = await BrowserTestUtils.openNewForegroundTab({ - gBrowser, - }); - await UrlbarTestUtils.inputIntoURLBar(window, userTypedTabText); + // Create a tab with userTypedValue. + const userTypedTabText = "test"; + const userTypedTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + }); + await UrlbarTestUtils.inputIntoURLBar(window, userTypedTabText); - // Create an empty tab. - const emptyTab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + // Create an empty tab. + const emptyTab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); - registerCleanupFunction(async () => { - await PlacesUtils.history.clear(); - BrowserTestUtils.removeTab(webpageTab); - BrowserTestUtils.removeTab(userTypedTab); - BrowserTestUtils.removeTab(emptyTab); - }); + async function cleanup() { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(webpageTab); + BrowserTestUtils.removeTab(userTypedTab); + BrowserTestUtils.removeTab(emptyTab); + } - await doSelectAndFocusTest({ - targetTab: webpageTab, - targetSelectionStart: 0, - targetSelectionEnd: 0, - anotherTab: userTypedTab, - }); - await doSelectAndFocusTest({ - targetTab: webpageTab, - targetSelectionStart: 2, - targetSelectionEnd: 5, - anotherTab: userTypedTab, - }); - await doSelectAndFocusTest({ - targetTab: webpageTab, - targetSelectionStart: webpageTabURL.length, - targetSelectionEnd: webpageTabURL.length, - anotherTab: userTypedTab, - }); - await doSelectAndFocusTest({ - targetTab: webpageTab, - targetSelectionStart: 0, - targetSelectionEnd: 0, - anotherTab: emptyTab, - }); - await doSelectAndFocusTest({ - targetTab: userTypedTab, - targetSelectionStart: 0, - targetSelectionEnd: 0, - anotherTab: webpageTab, - }); - await doSelectAndFocusTest({ - targetTab: userTypedTab, - targetSelectionStart: 0, - targetSelectionEnd: 0, - anotherTab: emptyTab, - }); - await doSelectAndFocusTest({ - targetTab: userTypedTab, - targetSelectionStart: 1, - targetSelectionEnd: 2, - anotherTab: emptyTab, - }); - await doSelectAndFocusTest({ - targetTab: userTypedTab, - targetSelectionStart: userTypedTabText.length, - targetSelectionEnd: userTypedTabText.length, - anotherTab: emptyTab, - }); - await doSelectAndFocusTest({ - targetTab: emptyTab, - targetSelectionStart: 0, - targetSelectionEnd: 0, - anotherTab: webpageTab, - }); - await doSelectAndFocusTest({ - targetTab: emptyTab, - targetSelectionStart: 0, - targetSelectionEnd: 0, - anotherTab: userTypedTab, - }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: userTypedTab, + }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: 2, + targetSelectionEnd: 5, + anotherTab: userTypedTab, + }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: webpageTabURL.length, + targetSelectionEnd: webpageTabURL.length, + anotherTab: userTypedTab, + }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: webpageTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: 1, + targetSelectionEnd: 2, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: userTypedTabText.length, + targetSelectionEnd: userTypedTabText.length, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: emptyTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: webpageTab, + }); + await doSelectAndFocusTest({ + targetTab: emptyTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: userTypedTab, + }); + + await cleanup(); + } }); async function doSelectAndFocusTest({ @@ -197,6 +201,13 @@ async function doSelectAndFocusTest({ targetSelectionStart, targetSelectionEnd ); + const targetSelectedText = getSelectedText(); + if (gURLBar.selectionStart != gURLBar.selectionEnd) { + Assert.ok( + targetSelectedText, + `Some text is selected: "${targetSelectedText}"` + ); + } const targetValue = gURLBar.value; // Switch to another tab. @@ -210,8 +221,9 @@ async function doSelectAndFocusTest({ Assert.equal(gURLBar.value, targetValue); Assert.equal(gURLBar.focused, targetFocus); if (gURLBar.focused) { - Assert.equal(gURLBar.selectionStart, targetSelectionStart); - Assert.equal(gURLBar.selectionEnd, targetSelectionEnd); + // Check the selected text rather than the selection indices, to keep + // untrimming into account. + Assert.equal(targetSelectedText, getSelectedText()); } else { Assert.equal(gURLBar.selectionStart, gURLBar.value.length); Assert.equal(gURLBar.selectionEnd, gURLBar.value.length); @@ -221,12 +233,21 @@ async function doSelectAndFocusTest({ function setURLBarFocus(focus) { if (focus) { - gURLBar.focus(); + // Simulate a user interaction, to eventually cause untrimming. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); } else { gURLBar.blur(); } } +function getSelectedText() { + return gURLBar.inputField.editor.selection.toStringWithFormat( + "text/plain", + Ci.nsIDocumentEncoder.OutputPreformatted | Ci.nsIDocumentEncoder.OutputRaw, + 0 + ); +} + async function switchTab(tab) { if (gBrowser.selectedTab !== tab) { EventUtils.synthesizeMouseAtCenter(tab, {}); 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 index 2ad6ee0e07..44a7ea64b2 100644 --- a/browser/components/urlbar/tests/browser/browser_less_common_selection_manipulations.js +++ b/browser/components/urlbar/tests/browser/browser_less_common_selection_manipulations.js @@ -238,51 +238,3 @@ function getTextWidth(inputText) { .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_oneOffs.js b/browser/components/urlbar/tests/browser/browser_oneOffs.js index 0c04f1e321..e517ea0a9a 100644 --- a/browser/components/urlbar/tests/browser/browser_oneOffs.js +++ b/browser/components/urlbar/tests/browser/browser_oneOffs.js @@ -30,7 +30,6 @@ add_setup(async function () { set: [ ["browser.search.separatePrivateDefault.ui.enabled", false], ["browser.urlbar.suggest.quickactions", false], - ["browser.urlbar.shortcuts.quickactions", true], ], }); @@ -943,7 +942,7 @@ async function doLocalShortcutsShownTest() { await rebuildPromise; let buttons = oneOffSearchButtons.localButtons; - Assert.equal(buttons.length, 4, "Expected number of local shortcuts"); + Assert.equal(buttons.length, 3, "Expected number of local shortcuts"); let expectedSource; let seenIDs = new Set(); @@ -963,9 +962,6 @@ async function doLocalShortcutsShownTest() { case "urlbar-engine-one-off-item-history": expectedSource = UrlbarUtils.RESULT_SOURCE.HISTORY; break; - case "urlbar-engine-one-off-item-actions": - expectedSource = UrlbarUtils.RESULT_SOURCE.ACTIONS; - break; default: Assert.ok(false, `Unexpected local shortcut ID: ${button.id}`); break; diff --git a/browser/components/urlbar/tests/browser/browser_quickactions.js b/browser/components/urlbar/tests/browser/browser_quickactions.js index ccf045d9e8..1fc4ef7cd6 100644 --- a/browser/components/urlbar/tests/browser/browser_quickactions.js +++ b/browser/components/urlbar/tests/browser/browser_quickactions.js @@ -10,32 +10,41 @@ ChromeUtils.defineESModuleGetters(this, { AppConstants: "resource://gre/modules/AppConstants.sys.mjs", UpdateService: "resource://gre/modules/UpdateService.sys.mjs", - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", + ActionsProviderQuickActions: + "resource:///modules/ActionsProviderQuickActions.sys.mjs", }); const DUMMY_PAGE = - "http://example.com/browser/browser/base/content/test/general/dummy_page.html"; + "https://example.com/browser/browser/base/content/test/general/dummy_page.html"; let testActionCalled = 0; +const assertAction = async name => { + await BrowserTestUtils.waitForCondition(() => + window.document.querySelector(`.urlbarView-action-btn[data-action=${name}]`) + ); + Assert.ok(true, `We found action "${name}`); +}; + +const hasQuickActions = win => + !!win.document.querySelector(".urlbarView-action-btn"); + add_setup(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ ["browser.urlbar.quickactions.enabled", true], - ["browser.urlbar.suggest.quickactions", true], - ["browser.urlbar.shortcuts.quickactions", true], + ["browser.urlbar.secondaryActions.featureGate", true], ], }); - UrlbarProviderQuickActions.addAction("testaction", { + ActionsProviderQuickActions.addAction("testaction", { commands: ["testaction"], label: "quickactions-downloads2", onPick: () => testActionCalled++, }); registerCleanupFunction(() => { - UrlbarProviderQuickActions.removeAction("testaction"); + ActionsProviderQuickActions.removeAction("testaction"); }); }); @@ -57,183 +66,16 @@ add_task(async function basic() { value: "testact", }); - Assert.equal( - UrlbarTestUtils.getResultCount(window), - 2, - "We matched the action" - ); + await assertAction("testaction"); info("The callback of the action is fired when selected"); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); - Assert.equal(testActionCalled, 1, "Test actionwas called"); -}); - -add_task(async function test_label_command() { - info("A prefix of the label matches"); - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "View Dow", - }); - Assert.equal( - UrlbarTestUtils.getResultCount(window), - 2, - "We matched the action" - ); - - let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); - Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); - Assert.equal(result.providerName, "quickactions"); - await UrlbarTestUtils.promisePopupClose(window, () => { - EventUtils.synthesizeKey("KEY_Escape"); - }); -}); - -add_task(async function enter_search_mode_button() { - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "test", - }); - - await clickQuickActionOneoffButton(); - - await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); - Assert.ok(true, "Actions are shown when we enter actions search mode."); - - await UrlbarTestUtils.exitSearchMode(window); - await UrlbarTestUtils.promisePopupClose(window); - EventUtils.synthesizeKey("KEY_Escape"); -}); - -add_task(async function enter_search_mode_oneoff_by_key() { - // Select actions oneoff button by keyboard. - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "", - }); - await UrlbarTestUtils.enterSearchMode(window); - const oneOffButtons = UrlbarTestUtils.getOneOffSearchButtons(window); - for (;;) { - EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); - if ( - oneOffButtons.selectedButton.source === UrlbarUtils.RESULT_SOURCE.ACTIONS - ) { - break; - } - } - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: " ", - }); - await UrlbarTestUtils.assertSearchMode(window, { - source: UrlbarUtils.RESULT_SOURCE.ACTIONS, - entry: "oneoff", - }); - - await UrlbarTestUtils.exitSearchMode(window); - await UrlbarTestUtils.promisePopupClose(window); - EventUtils.synthesizeKey("KEY_Escape"); -}); - -add_task(async function enter_search_mode_key() { - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "> ", - }); - await UrlbarTestUtils.assertSearchMode(window, { - source: UrlbarUtils.RESULT_SOURCE.ACTIONS, - entry: "typed", - }); - Assert.equal( - await hasQuickActions(window), - true, - "Actions are shown in search mode" - ); - await UrlbarTestUtils.exitSearchMode(window); - await UrlbarTestUtils.promisePopupClose(window); - EventUtils.synthesizeKey("KEY_Escape"); -}); - -add_task(async function test_disabled() { - UrlbarProviderQuickActions.addAction("disabledaction", { - commands: ["disabledaction"], - isActive: () => false, - label: "quickactions-restart", - }); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "disabled", - }); - - Assert.equal( - await hasQuickActions(window), - false, - "Result for quick actions is hidden" - ); - - await UrlbarTestUtils.promisePopupClose(window); - UrlbarProviderQuickActions.removeAction("disabledaction"); -}); - -/** - * The first part of this test confirms that when the screenshots component is enabled - * the screenshot quick action button will be enabled on about: pages. - * The second part confirms that when the screenshots extension is enabled the - * screenshot quick action button will be disbaled on about: pages. - */ -add_task(async function test_screenshot_enabled_or_disabled() { - let onLoaded = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - "about:blank" - ); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "about:blank" - ); - await onLoaded; - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "screenshot", - }); - Assert.equal( - UrlbarTestUtils.getResultCount(window), - 2, - "The action is displayed" - ); - let screenshotButton = window.document.querySelector( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ); - Assert.ok( - !screenshotButton.hasAttribute("disabled"), - "Screenshot button is enabled on about pages" - ); - - await UrlbarTestUtils.promisePopupClose(window); - EventUtils.synthesizeKey("KEY_Escape"); - - await SpecialPowers.pushPrefEnv({ - set: [["screenshots.browser.component.enabled", false]], - }); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "screenshot", - }); - Assert.equal( - await hasQuickActions(window), - false, - "Result for quick actions is hidden" - ); - - await UrlbarTestUtils.promisePopupClose(window); + Assert.equal(testActionCalled, 1, "Test action was called"); }); add_task(async function match_in_phrase() { - UrlbarProviderQuickActions.addAction("newtestaction", { + ActionsProviderQuickActions.addAction("newtestaction", { commands: ["matchingstring"], label: "quickactions-downloads2", }); @@ -243,304 +85,31 @@ add_task(async function match_in_phrase() { window, value: "Test we match at end of matchingstring", }); - Assert.equal( - UrlbarTestUtils.getResultCount(window), - 2, - "We matched the action" - ); - await UrlbarTestUtils.promisePopupClose(window); - EventUtils.synthesizeKey("KEY_Escape"); - UrlbarProviderQuickActions.removeAction("newtestaction"); -}); - -add_task(async function test_other_search_mode() { - let defaultEngine = await SearchTestUtils.promiseNewSearchEngine({ - url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", - }); - defaultEngine.alias = "testalias"; - let oldDefaultEngine = await Services.search.getDefault(); - Services.search.setDefault( - defaultEngine, - Ci.nsISearchService.CHANGE_REASON_UNKNOWN - ); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: defaultEngine.alias + " ", - }); - Assert.equal( - UrlbarTestUtils.getResultCount(window), - 0, - "The results should be empty as no actions are displayed in other search modes" - ); - await UrlbarTestUtils.assertSearchMode(window, { - engineName: defaultEngine.name, - entry: "typed", - }); - await UrlbarTestUtils.promisePopupClose(window, () => { - EventUtils.synthesizeKey("KEY_Escape"); - }); - Services.search.setDefault( - oldDefaultEngine, - Ci.nsISearchService.CHANGE_REASON_UNKNOWN - ); -}); - -add_task(async function test_no_quickactions_suggestions() { - await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.urlbar.suggest.quickactions", false], - ["screenshots.browser.component.enabled", true], - ], - }); - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "screenshot", - }); - Assert.ok( - !window.document.querySelector( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ), - "Screenshot button is not suggested" - ); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "> screenshot", - }); - Assert.ok( - window.document.querySelector( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ), - "Screenshot button is suggested" - ); - - await UrlbarTestUtils.promisePopupClose(window); - EventUtils.synthesizeKey("KEY_Escape"); - - await SpecialPowers.popPrefEnv(); -}); - -add_task(async function test_quickactions_disabled() { - await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.urlbar.quickactions.enabled", false], - ["browser.urlbar.suggest.quickactions", true], - ], - }); - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "screenshot", - }); - - Assert.ok( - !window.document.querySelector( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ), - "Screenshot button is not suggested" - ); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "> screenshot", - }); - Assert.ok( - !window.document.querySelector( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ), - "Screenshot button is not suggested" - ); - + await assertAction("newtestaction"); await UrlbarTestUtils.promisePopupClose(window); EventUtils.synthesizeKey("KEY_Escape"); - - await SpecialPowers.popPrefEnv(); -}); - -let COMMANDS_TESTS = [ - { - cmd: "add-ons", - uri: "about:addons", - testFun: async () => isSelected("button[name=discover]"), - }, - { - cmd: "plugins", - uri: "about:addons", - testFun: async () => isSelected("button[name=plugin]"), - }, - { - cmd: "extensions", - uri: "about:addons", - testFun: async () => isSelected("button[name=extension]"), - }, - { - cmd: "themes", - uri: "about:addons", - testFun: async () => isSelected("button[name=theme]"), - }, - { - cmd: "add-ons", - setup: async () => { - const onLoad = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - "http://example.com/" - ); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "http://example.com/" - ); - await onLoad; - }, - uri: "about:addons", - isNewTab: true, - testFun: async () => isSelected("button[name=discover]"), - }, - { - cmd: "plugins", - setup: async () => { - const onLoad = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - "http://example.com/" - ); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "http://example.com/" - ); - await onLoad; - }, - uri: "about:addons", - isNewTab: true, - testFun: async () => isSelected("button[name=plugin]"), - }, - { - cmd: "extensions", - setup: async () => { - const onLoad = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - "http://example.com/" - ); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "http://example.com/" - ); - await onLoad; - }, - uri: "about:addons", - isNewTab: true, - testFun: async () => isSelected("button[name=extension]"), - }, - { - cmd: "themes", - setup: async () => { - const onLoad = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - "http://example.com/" - ); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "http://example.com/" - ); - await onLoad; - }, - uri: "about:addons", - isNewTab: true, - testFun: async () => isSelected("button[name=theme]"), - }, -]; - -let isSelected = async selector => - SpecialPowers.spawn(gBrowser.selectedBrowser, [selector], arg => { - return ContentTaskUtils.waitForCondition(() => - content.document.querySelector(arg)?.hasAttribute("selected") - ); - }); - -add_task(async function test_pages() { - for (const { cmd, uri, setup, isNewTab, testFun } of COMMANDS_TESTS) { - info(`Testing ${cmd} command is triggered`); - let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); - - if (setup) { - info("Setup"); - await setup(); - } - - let onLoad = isNewTab - ? BrowserTestUtils.waitForNewTab(gBrowser, uri, true) - : BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: cmd, - }); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); - EventUtils.synthesizeKey("KEY_Enter", {}, window); - - const newTab = await onLoad; - - Assert.ok( - await testFun(), - `The command "${cmd}" passed completed its test` - ); - - if (isNewTab) { - await BrowserTestUtils.removeTab(newTab); - } - await BrowserTestUtils.removeTab(tab); - } + ActionsProviderQuickActions.removeAction("newtestaction"); }); -const assertActionButtonStatus = async (name, expectedEnabled, description) => { - await BrowserTestUtils.waitForCondition(() => - window.document.querySelector(`[data-key=${name}]`) - ); - const target = window.document.querySelector(`[data-key=${name}]`); - Assert.equal(!target.hasAttribute("disabled"), expectedEnabled, description); -}; - add_task(async function test_viewsource() { info("Check the button status of when the page is not web content"); const tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser, - opening: "about:home", + opening: "https://example.com", waitForLoad: true, }); - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "viewsource", - }); - await assertActionButtonStatus( - "viewsource", - true, - "Should be enabled even if the page is not web content" - ); - info("Check the button status of when the page is web content"); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "http://example.com" - ); - await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); await UrlbarTestUtils.promiseAutocompleteResultPopup({ window, value: "viewsource", }); - await assertActionButtonStatus( - "viewsource", - true, - "Should be enabled on web content as well" - ); info("Do view source action"); const onLoad = BrowserTestUtils.waitForNewTab( gBrowser, - "view-source:http://example.com/" + "view-source:https://example.com/" ); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); const viewSourceTab = await onLoad; @@ -551,7 +120,7 @@ add_task(async function test_viewsource() { }); Assert.equal( - await hasQuickActions(window), + hasQuickActions(window), false, "Result for quick actions is hidden" ); @@ -575,7 +144,7 @@ async function doAlertDialogTest({ input, dialogContentURI }) { }, }); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await onDialog; @@ -601,16 +170,16 @@ add_task(async function test_clear() { }); }); -async function doUpdateActionTest(isActiveExpected, description) { +async function doUpdateActionTest(isActiveExpected) { await UrlbarTestUtils.promiseAutocompleteResultPopup({ window, value: "update", }); if (isActiveExpected) { - await assertActionButtonStatus("update", isActiveExpected, description); + await assertAction("update"); } else { - Assert.equal(await hasQuickActions(window), false, description); + Assert.equal(hasQuickActions(window), false, "No QuickActions were shown"); } } @@ -644,43 +213,6 @@ add_task(async function test_update() { } }); -async function hasQuickActions(win) { - for (let i = 0, count = UrlbarTestUtils.getResultCount(win); i < count; i++) { - const { result } = await UrlbarTestUtils.getDetailsOfResultAt(win, i); - if (result.providerName === "quickactions") { - return true; - } - } - return false; -} - -add_task(async function test_show_in_zero_prefix() { - for (const minimumSearchString of [0, 3]) { - info( - `Test when quickactions.minimumSearchString pref is ${minimumSearchString}` - ); - await SpecialPowers.pushPrefEnv({ - set: [ - [ - "browser.urlbar.quickactions.minimumSearchString", - minimumSearchString, - ], - ], - }); - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "", - }); - - Assert.equal( - await hasQuickActions(window), - !minimumSearchString, - "Result for quick actions is as expected" - ); - await SpecialPowers.popPrefEnv(); - } -}); - add_task(async function test_whitespace() { info("Test with quickactions.showInZeroPrefix pref is false"); await SpecialPowers.pushPrefEnv({ @@ -691,7 +223,7 @@ add_task(async function test_whitespace() { value: " ", }); Assert.equal( - await hasQuickActions(window), + hasQuickActions(window), false, "Result for quick actions is not shown" ); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_commands.js b/browser/components/urlbar/tests/browser/browser_quickactions_commands.js new file mode 100644 index 0000000000..19b8d31ada --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions_commands.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test QuickActions. + */ + +"use strict"; + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.secondaryActions.featureGate", true], + ], + }); +}); + +let COMMANDS_TESTS = [ + { + cmd: "add-ons", + uri: "about:addons", + testFun: async () => isSelected("button[name=discover]"), + }, + { + cmd: "plugins", + uri: "about:addons", + testFun: async () => isSelected("button[name=plugin]"), + }, + { + cmd: "extensions", + uri: "about:addons", + testFun: async () => isSelected("button[name=extension]"), + }, + { + cmd: "themes", + uri: "about:addons", + testFun: async () => isSelected("button[name=theme]"), + }, + { + cmd: "add-ons", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.com/" + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=discover]"), + }, + { + cmd: "plugins", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.com/" + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=plugin]"), + }, + { + cmd: "extensions", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.com/" + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=extension]"), + }, + { + cmd: "themes", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.com/" + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=theme]"), + }, +]; + +let isSelected = async selector => + SpecialPowers.spawn(gBrowser.selectedBrowser, [selector], arg => { + return ContentTaskUtils.waitForCondition(() => + content.document.querySelector(arg)?.hasAttribute("selected") + ); + }); + +add_task(async function test_pages() { + for (const { cmd, uri, setup, isNewTab, testFun } of COMMANDS_TESTS) { + info(`Testing ${cmd} command is triggered`); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + if (setup) { + info("Setup"); + await setup(); + } + + let onLoad = isNewTab + ? BrowserTestUtils.waitForNewTab(gBrowser, uri, true) + : BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: cmd, + }); + EventUtils.synthesizeKey("KEY_Tab", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + + const newTab = await onLoad; + + Assert.ok( + await testFun(), + `The command "${cmd}" passed completed its test` + ); + + if (isNewTab) { + await BrowserTestUtils.removeTab(newTab); + } + await BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js b/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js index 1e1e92fb31..bee98d42af 100644 --- a/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js +++ b/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js @@ -17,7 +17,7 @@ add_setup(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ ["browser.urlbar.quickactions.enabled", true], - ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.secondaryActions.featureGate", true], ["browser.urlbar.shortcuts.quickactions", true], ], }); @@ -25,21 +25,14 @@ add_setup(async function setup() { const assertActionButtonStatus = async (name, expectedEnabled, description) => { await BrowserTestUtils.waitForCondition(() => - window.document.querySelector(`[data-key=${name}]`) + window.document.querySelector(`[data-action=${name}]`) ); - const target = window.document.querySelector(`[data-key=${name}]`); + const target = window.document.querySelector(`[data-action=${name}]`); Assert.equal(!target.hasAttribute("disabled"), expectedEnabled, description); }; -async function hasQuickActions(win) { - for (let i = 0, count = UrlbarTestUtils.getResultCount(win); i < count; i++) { - const { result } = await UrlbarTestUtils.getDetailsOfResultAt(win, i); - if (result.providerName === "quickactions") { - return true; - } - } - return false; -} +const hasQuickActions = win => + !!win.document.querySelector(".urlbarView-action-btn"); add_task(async function test_inspector() { const testData = [ @@ -129,7 +122,7 @@ add_task(async function test_inspector() { ); } else { Assert.equal( - await hasQuickActions(window), + hasQuickActions(window), false, "Result for quick actions is not shown since the inspector tool is disabled" ); @@ -142,7 +135,7 @@ add_task(async function test_inspector() { } info("Do inspect action"); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await BrowserTestUtils.waitForCondition( () => DevToolsShim.hasToolboxForTab(gBrowser.selectedTab), @@ -160,7 +153,7 @@ add_task(async function test_inspector() { value: "inspector", }); Assert.equal( - await hasQuickActions(window), + hasQuickActions(window), false, "Result for quick actions is not shown since the inspector is already opening" ); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js b/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js index c81442f0f5..c83fdff441 100644 --- a/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js +++ b/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js @@ -34,11 +34,18 @@ async function clickQuickActionOneoffButton() { }); } +const assertAction = async name => { + await BrowserTestUtils.waitForCondition(() => + window.document.querySelector(`.urlbarView-action-btn[data-action=${name}]`) + ); + Assert.ok(true, `We found action "${name}`); +}; + add_setup(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ ["browser.urlbar.quickactions.enabled", true], - ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.secondaryActions.featureGate", true], ["browser.urlbar.shortcuts.quickactions", true], ], }); @@ -61,17 +68,10 @@ add_task(async function test_screenshot() { window, value: "screenshot", }); - Assert.equal( - UrlbarTestUtils.getResultCount(window), - 2, - "We matched the action" - ); - let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); - Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); - Assert.equal(result.providerName, "quickactions"); + await assertAction("screenshot"); info("Trigger the screenshot mode"); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await TestUtils.waitForCondition( isScreenshotInitialized, @@ -104,38 +104,6 @@ add_task(async function search_mode_on_webpage() { }); await UrlbarTestUtils.promiseSearchComplete(window); - info("Enter quick action search mode"); - await clickQuickActionOneoffButton(); - await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); - Assert.ok(true, "Actions are shown when we enter actions search mode."); - - info("Trigger the screenshot mode"); - const initialActionButtons = window.document.querySelectorAll( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ); - let screenshotButton; - for (let i = 0; i < initialActionButtons.length; i++) { - const item = initialActionButtons.item(i); - if (item.dataset.key === "screenshot") { - screenshotButton = item; - break; - } - } - EventUtils.synthesizeMouseAtCenter(screenshotButton, {}, window); - await TestUtils.waitForCondition( - isScreenshotInitialized, - "Screenshot component is active", - 200, - 100 - ); - - info("Press Escape to exit screenshot mode"); - EventUtils.synthesizeKey("KEY_Escape", {}, window); - await TestUtils.waitForCondition( - async () => !(await isScreenshotInitialized()), - "Screenshot component has been dismissed" - ); - info("Check the urlbar state"); Assert.equal(gURLBar.value, UrlbarTestUtils.trimURL("https://example.com")); Assert.equal(gURLBar.getAttribute("pageproxystate"), "valid"); @@ -146,23 +114,7 @@ add_task(async function search_mode_on_webpage() { }); await UrlbarTestUtils.promiseSearchComplete(window); - info("Enter quick action search mode again"); - await clickQuickActionOneoffButton(); - await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); - const finalActionButtons = window.document.querySelectorAll( - ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-button" - ); - - info("Check the action buttons and the urlbar"); - Assert.equal( - finalActionButtons.length, - initialActionButtons.length, - "The same buttons as initially displayed will display" - ); - Assert.equal(gURLBar.value, ""); - info("Clean up"); - await UrlbarTestUtils.exitSearchMode(window); await UrlbarTestUtils.promisePopupClose(window); EventUtils.synthesizeKey("KEY_Escape"); BrowserTestUtils.removeTab(tab); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js b/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js index abac861931..ee1f3cab62 100644 --- a/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js +++ b/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js @@ -13,7 +13,7 @@ add_setup(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ ["browser.urlbar.quickactions.enabled", true], - ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.secondaryActions.featureGate", true], ["browser.urlbar.shortcuts.quickactions", true], ], }); @@ -90,7 +90,7 @@ add_task(async function test_about_pages() { window, value: firstInput, }); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); } await onLoad; @@ -106,7 +106,7 @@ add_task(async function test_about_pages() { window, value: secondInput || firstInput, }); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); Assert.equal( gBrowser.selectedTab, @@ -158,7 +158,7 @@ add_task(async function test_about_addons_pages() { window, value: cmd, }); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); Assert.ok(await testFun(), "The page content is correct"); @@ -175,7 +175,7 @@ add_task(async function test_about_addons_pages() { window, value: cmd, }); - EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Tab", {}, window); EventUtils.synthesizeKey("KEY_Enter", {}, window); await BrowserTestUtils.waitForCondition(() => testFun()); Assert.ok(true, "The tab correspondent action is selected"); diff --git a/browser/components/urlbar/tests/browser/browser_secondaryActions.js b/browser/components/urlbar/tests/browser/browser_secondaryActions.js new file mode 100644 index 0000000000..7e03ae036c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_secondaryActions.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Basic tests for secondary Actions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ActionsProviderQuickActions: + "resource:///modules/ActionsProviderQuickActions.sys.mjs", +}); + +let testActionCalled = 0; + +let loadURI = async (browser, uri) => { + let onLoaded = BrowserTestUtils.browserLoaded(browser, false, uri); + BrowserTestUtils.startLoadingURIString(browser, uri); + return onLoaded; +}; + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.secondaryActions.featureGate", true], + ["browser.urlbar.contextualSearch.enabled", true], + ], + }); + + ActionsProviderQuickActions.addAction("testaction", { + commands: ["testaction"], + actionKey: "testaction", + label: "quickactions-downloads2", + onPick: () => testActionCalled++, + }); + + registerCleanupFunction(() => { + ActionsProviderQuickActions.removeAction("testaction"); + }); +}); + +add_task(async function test_quickaction() { + info("Match an installed quickaction and trigger it via tab"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "testact", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We matched the action" + ); + + info("The callback of the action is fired when selected"); + EventUtils.synthesizeKey("KEY_Tab", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + Assert.equal(testActionCalled, 1, "Test action was called"); +}); + +add_task(async function test_switchtab() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await loadURI(win.gBrowser, "https://example.com/"); + + info("Open a new tab, type in the urlbar and switch to previous tab"); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "example", + }); + + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + + is(win.gBrowser.tabs.length, 1, "We switched to previous tab"); + is( + win.gBrowser.currentURI.spec, + "https://example.com/", + "We switched to previous tab" + ); + + info( + "Open a new tab, type in the urlbar, select result and open url in current tab" + ); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "example", + }); + + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await BrowserTestUtils.browserLoaded(win.gBrowser); + is(win.gBrowser.tabs.length, 2, "We switched to previous tab"); + is( + win.gBrowser.currentURI.spec, + "https://example.com/", + "We opened in current tab" + ); + + BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_sitesearch() { + await SearchTestUtils.installSearchExtension({ + name: "Contextual", + search_url: "https://example.com/browser", + }); + + let ENGINE_TEST_URL = "https://example.com/"; + await loadURI(gBrowser.selectedBrowser, ENGINE_TEST_URL); + + const query = "search"; + let engine = Services.search.getEngineByName("Contextual"); + const [expectedUrl] = UrlbarUtils.getSearchQueryUrl(engine, query); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "sea", + }); + + let onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedUrl + ); + gURLBar.value = query; + UrlbarTestUtils.fireInputEvent(window); + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + expectedUrl, + "Selecting the contextual search result opens the search URL" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_strip_on_share.js b/browser/components/urlbar/tests/browser/browser_strip_on_share.js index 9e045cee9c..ec3f96425a 100644 --- a/browser/components/urlbar/tests/browser/browser_strip_on_share.js +++ b/browser/components/urlbar/tests/browser/browser_strip_on_share.js @@ -101,6 +101,32 @@ add_task(async function testQueryParamIsNotStrippedForWrongSiteSpecific() { }); }); +// Ensuring site specific parameters are stripped regardless +// of capitalization in the URI +add_task(async function testQueryParamIsStrippedWhenParamIsCapitalized() { + let originalUrl = "https://www.example.com/?TEST_1=1234"; + let shortenedUrl = "https://www.example.com/"; + await testMenuItemEnabled({ + selectWholeUrl: true, + validUrl: originalUrl, + strippedUrl: shortenedUrl, + useTestList: true, + }); +}); + +// Ensuring site specific parameters are stripped regardless +// of capitalization in the URI +add_task(async function testQueryParamIsStrippedWhenParamIsCapitalized() { + let originalUrl = "https://www.example.com/?test_5=1234"; + let shortenedUrl = "https://www.example.com/"; + await testMenuItemEnabled({ + selectWholeUrl: true, + validUrl: originalUrl, + strippedUrl: shortenedUrl, + useTestList: true, + }); +}); + /** * Opens a new tab, opens the ulr bar context menu and checks that the strip-on-share menu item is not visible * @@ -163,7 +189,7 @@ async function testMenuItemEnabled({ topLevelSites: ["*"], }, example: { - queryParams: ["test_2", "test_1"], + queryParams: ["test_2", "test_1", "TEST_5"], topLevelSites: ["www.example.com"], }, exampleNet: { diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js deleted file mode 100644 index b29807900b..0000000000 --- a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js +++ /dev/null @@ -1,133 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - * http://creativecommons.org/publicdomain/zero/1.0/ - */ - -/** - * This file tests urlbar telemetry for quickactions. - */ - -"use strict"; - -ChromeUtils.defineESModuleGetters(this, { - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", -}); - -let testActionCalled = 0; - -add_setup(async function setup() { - await SpecialPowers.pushPrefEnv({ - set: [ - ["browser.urlbar.suggest.quickactions", true], - ["browser.urlbar.quickactions.enabled", true], - ], - }); - - UrlbarProviderQuickActions.addAction("testaction", { - commands: ["testaction"], - label: "quickactions-downloads2", - onPick: () => testActionCalled++, - }); - - registerCleanupFunction(() => { - UrlbarProviderQuickActions.removeAction("testaction"); - }); -}); - -add_task(async function test() { - const histograms = snapshotHistograms(); - - // Do a search to show the quickaction. - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "testaction", - waitForFocus, - fireInputEvent: true, - }); - - await UrlbarTestUtils.promisePopupClose(window, () => { - EventUtils.synthesizeKey("KEY_ArrowDown"); - EventUtils.synthesizeKey("KEY_Enter"); - }); - - Assert.equal(testActionCalled, 1, "Test action was called"); - - TelemetryTestUtils.assertHistogram( - histograms.resultMethodHist, - UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, - 1 - ); - - let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); - TelemetryTestUtils.assertKeyedScalar( - scalars, - `urlbar.picked.quickaction`, - 1, - 1 - ); - - TelemetryTestUtils.assertKeyedScalar( - scalars, - "quickaction.picked", - "testaction-10", - 1 - ); - - TelemetryTestUtils.assertKeyedScalar( - scalars, - "quickaction.impression", - "testaction-10", - 1 - ); - - // Clean up for subsequent tests. - gURLBar.handleRevert(); -}); - -add_task(async function test_impressions() { - UrlbarProviderQuickActions.addAction("testaction2", { - commands: ["testaction2"], - label: "quickactions-downloads2", - onPick: () => {}, - }); - - await UrlbarTestUtils.promiseAutocompleteResultPopup({ - window, - value: "testaction", - waitForFocus, - fireInputEvent: true, - }); - - await UrlbarTestUtils.promisePopupClose(window, () => { - EventUtils.synthesizeKey("KEY_ArrowDown"); - EventUtils.synthesizeKey("KEY_Enter"); - }); - - let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); - - TelemetryTestUtils.assertKeyedScalar( - scalars, - "quickaction.impression", - `testaction-10`, - 1 - ); - TelemetryTestUtils.assertKeyedScalar( - scalars, - "quickaction.impression", - `testaction2-10`, - 1 - ); - - UrlbarProviderQuickActions.removeAction("testaction2"); - gURLBar.handleRevert(); -}); - -function snapshotHistograms() { - Services.telemetry.clearScalars(); - Services.telemetry.clearEvents(); - return { - resultMethodHist: TelemetryTestUtils.getAndClearHistogram( - "FX_URLBAR_SELECTED_RESULT_METHOD" - ), - }; -} diff --git a/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js b/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js index 10110a8928..0625a3397f 100644 --- a/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js +++ b/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js @@ -6,16 +6,16 @@ // Test selection on result view by mouse. ChromeUtils.defineESModuleGetters(this, { - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", + ActionsProviderQuickActions: + "resource:///modules/ActionsProviderQuickActions.sys.mjs", }); add_setup(async function () { await SpecialPowers.pushPrefEnv({ set: [ + ["browser.urlbar.secondaryActions.featureGate", true], ["browser.urlbar.quickactions.enabled", true], ["browser.urlbar.suggest.quickactions", true], - ["browser.urlbar.shortcuts.quickactions", true], ], }); @@ -23,7 +23,7 @@ add_setup(async function () { await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); - UrlbarProviderQuickActions.addAction("test-addons", { + ActionsProviderQuickActions.addAction("test-addons", { commands: ["test-addons"], label: "quickactions-addons", onPick: () => @@ -32,7 +32,7 @@ add_setup(async function () { "about:about" ), }); - UrlbarProviderQuickActions.addAction("test-downloads", { + ActionsProviderQuickActions.addAction("test-downloads", { commands: ["test-downloads"], label: "quickactions-downloads2", onPick: () => @@ -43,40 +43,22 @@ add_setup(async function () { }); registerCleanupFunction(function () { - UrlbarProviderQuickActions.removeAction("test-addons"); - UrlbarProviderQuickActions.removeAction("test-downloads"); + ActionsProviderQuickActions.removeAction("test-addons"); + ActionsProviderQuickActions.removeAction("test-downloads"); }); }); add_task(async function basic() { const testData = [ { - description: "Normal result to quick action button", - mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", - mouseup: ".urlbarView-quickaction-button[data-key=test-downloads]", - expected: "about:downloads", - }, - { - description: "Normal result to out of result", - mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", - mouseup: "body", - expected: false, - }, - { description: "Quick action button to normal result", - mousedown: ".urlbarView-quickaction-button[data-key=test-addons]", + mousedown: ".urlbarView-action-btn[data-action=test-addons]", mouseup: ".urlbarView-row:nth-child(1)", expected: "https://example.com/?q=test", }, { - description: "Quick action button to quick action button", - mousedown: ".urlbarView-quickaction-button[data-key=test-addons]", - mouseup: ".urlbarView-quickaction-button[data-key=test-downloads]", - expected: "about:downloads", - }, - { description: "Quick action button to out of result", - mousedown: ".urlbarView-quickaction-button[data-key=test-downloads]", + mousedown: ".urlbarView-action-btn[data-action=test-addons]", mouseup: "body", expected: false, }, @@ -148,7 +130,7 @@ add_task(async function outOfBrowser() { }, { description: "Quick action button to out of browser", - mousedown: ".urlbarView-quickaction-button[data-key=test-addons]", + mousedown: ".urlbarView-action-btn[data-action=test-addons]", }, ]; @@ -204,22 +186,22 @@ add_task(async function withSelectionByKeyboard() { mouseup: "body", expected: { selectedElementByKey: - "#urlbar-results .urlbarView-quickaction-button[selected]", + "#urlbar-results .urlbarView-action-btn[selected]", selectedElementAfterMouseDown: - "#urlbar-results .urlbarView-quickaction-button[selected]", + "#urlbar-results .urlbarView-action-btn[selected]", actionedPage: false, }, }, { - description: "Select normal result, then click on about:downloads", - mousedown: ".urlbarView-quickaction-button[data-key=test-downloads]", - mouseup: ".urlbarView-quickaction-button[data-key=test-downloads]", + description: "Select normal result, then click on about:addons", + mousedown: ".urlbarView-action-btn[data-action=test-addons]", + mouseup: ".urlbarView-action-btn[data-action=test-addons]", expected: { selectedElementByKey: "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", selectedElementAfterMouseDown: - ".urlbarView-quickaction-button[data-key=test-downloads]", - actionedPage: "about:downloads", + ".urlbarView-action-btn[data-action=test-addons]", + actionedPage: "about:about", }, }, ]; @@ -244,11 +226,7 @@ add_task(async function withSelectionByKeyboard() { ]); if (arrowDown) { - EventUtils.synthesizeKey( - "KEY_ArrowDown", - { repeat: arrowDown }, - window - ); + EventUtils.synthesizeKey("KEY_Tab", { repeat: arrowDown }, window); } let [selectedElementByKey] = await waitForElements([ diff --git a/browser/components/urlbar/tests/browser/head.js b/browser/components/urlbar/tests/browser/head.js index f78624e68e..73f9fda3a9 100644 --- a/browser/components/urlbar/tests/browser/head.js +++ b/browser/components/urlbar/tests/browser/head.js @@ -161,7 +161,7 @@ async function search({ // Set the input value and move the caret to the end to simulate the user // typing. It's important the caret is at the end because otherwise autofill // won't happen. - gURLBar.value = searchString; + gURLBar._setValue(searchString, { allowTrim: false }); gURLBar.inputField.setSelectionRange( searchString.length, searchString.length @@ -173,28 +173,21 @@ async function search({ // autofill before the search completes. UrlbarTestUtils.fireInputEvent(window); - // Subtract the protocol length, when the searchString contains the https:// - // protocol and trimHttps is enabled. - let trimmedProtocolWSlashes = UrlbarTestUtils.getTrimmedProtocolWithSlashes(); - let selectionOffset = searchString.includes(trimmedProtocolWSlashes) - ? trimmedProtocolWSlashes.length - : 0; - // Check the input value and selection immediately, before waiting on the // search to complete. Assert.equal( gURLBar.value, - UrlbarTestUtils.trimURL(valueBefore), + valueBefore, "gURLBar.value before the search completes" ); Assert.equal( gURLBar.selectionStart, - searchString.length - selectionOffset, + searchString.length, "gURLBar.selectionStart before the search completes" ); Assert.equal( gURLBar.selectionEnd, - valueBefore.length - selectionOffset, + valueBefore.length, "gURLBar.selectionEnd before the search completes" ); @@ -205,17 +198,17 @@ async function search({ // Check the final value after the results arrived. Assert.equal( gURLBar.value, - UrlbarTestUtils.trimURL(valueAfter), + valueAfter, "gURLBar.value after the search completes" ); Assert.equal( gURLBar.selectionStart, - searchString.length - selectionOffset, + searchString.length, "gURLBar.selectionStart after the search completes" ); Assert.equal( gURLBar.selectionEnd, - valueAfter.length - selectionOffset, + valueAfter.length, "gURLBar.selectionEnd after the search completes" ); @@ -227,7 +220,7 @@ async function search({ ); Assert.strictEqual( gURLBar._autofillPlaceholder.value, - UrlbarTestUtils.trimURL(placeholderAfter), + placeholderAfter, "gURLBar._autofillPlaceholder.value after the search completes" ); } else { @@ -246,3 +239,51 @@ async function search({ "First result is an autofill result iff a placeholder is expected" ); } + +function selectWithMouseDrag(fromX, toX, win = window) { + let target = win.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, win = window) { + let target = win.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/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js index ce69d30517..f930b28f59 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js @@ -59,9 +59,9 @@ add_task(async function recent_search() { assert: () => assertAbandonmentTelemetry([ { - groups: "recent_search,suggested_index", - results: "recent_search,action", - n_results: 2, + groups: "recent_search", + results: "recent_search", + n_results: 1, }, ]), }); @@ -114,9 +114,9 @@ add_task(async function top_site() { assert: () => assertAbandonmentTelemetry([ { - groups: "top_site,suggested_index", - results: "top_site,action", - n_results: 2, + groups: "top_site", + results: "top_site", + n_results: 1, }, ]), }); @@ -128,9 +128,9 @@ add_task(async function clipboard() { assert: () => assertAbandonmentTelemetry([ { - groups: "general,suggested_index", - results: "clipboard,action", - n_results: 2, + groups: "general", + results: "clipboard", + n_results: 1, }, ]), }); @@ -170,9 +170,9 @@ add_task(async function general() { assert: () => assertAbandonmentTelemetry([ { - groups: "heuristic,suggested_index,general", - results: "search_engine,action,bookmark", - n_results: 3, + groups: "heuristic,general", + results: "search_engine,bookmark", + n_results: 2, }, ]), }); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js index 7edcc47a30..45f4b79e7c 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js @@ -45,10 +45,3 @@ add_task(async function tabs() { assert: () => assertAbandonmentTelemetry([{ search_mode: "tabs" }]), }); }); - -add_task(async function actions() { - await doActionsTest({ - trigger: () => doBlur(), - assert: () => assertAbandonmentTelemetry([{ search_mode: "actions" }]), - }); -}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js index 04ef7e9757..1668470714 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js @@ -196,7 +196,6 @@ add_task(async function enter_to_reload_current_url() { await BrowserTestUtils.waitForCondition( () => window.document.activeElement === gURLBar.inputField ); - await UrlbarTestUtils.promiseSearchComplete(window); // Press Enter key to reload the page without selecting any suggestions. await doEnter(); @@ -213,8 +212,8 @@ add_task(async function enter_to_reload_current_url() { selected_result: "input_field", selected_result_subtype: "", provider: undefined, - results: "action", - groups: "suggested_index", + results: "", + groups: "", }, ]); }); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js index d46c874403..a0ef61dd19 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js @@ -59,9 +59,9 @@ add_task(async function recent_search() { assert: () => assertEngagementTelemetry([ { - groups: "recent_search,suggested_index", - results: "recent_search,action", - n_results: 2, + groups: "recent_search", + results: "recent_search", + n_results: 1, }, ]), }); @@ -114,9 +114,9 @@ add_task(async function top_site() { assert: () => assertEngagementTelemetry([ { - groups: "top_site,suggested_index", - results: "top_site,action", - n_results: 2, + groups: "top_site", + results: "top_site", + n_results: 1, }, ]), }); @@ -128,9 +128,9 @@ add_task(async function clipboard() { assert: () => assertEngagementTelemetry([ { - groups: "general,suggested_index", - results: "clipboard,action", - n_results: 2, + groups: "general", + results: "clipboard", + n_results: 1, }, ]), }); @@ -170,9 +170,9 @@ add_task(async function general() { assert: () => assertEngagementTelemetry([ { - groups: "heuristic,suggested_index,general", - results: "search_engine,action,bookmark", - n_results: 3, + groups: "heuristic,general", + results: "search_engine,bookmark", + n_results: 2, }, ]), }); @@ -255,6 +255,7 @@ add_task(async function always_empty_if_drop_go() { await doTest(async () => { // Open the results view once. + await addTopSites("https://example.com/"); await showResultByArrowDown(); await UrlbarTestUtils.promisePopupClose(window); @@ -282,6 +283,7 @@ add_task(async function always_empty_if_paste_go() { await doTest(async () => { // Open the results view once. + await addTopSites("https://example.com/"); await showResultByArrowDown(); await UrlbarTestUtils.promisePopupClose(window); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js index 2866186c30..d2deacf597 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js @@ -42,6 +42,7 @@ add_task(async function dropped() { }); await doTest(async () => { + await addTopSites("https://example.com/"); await showResultByArrowDown(); await doDropAndGo("example.com"); @@ -67,6 +68,7 @@ add_task(async function pasted() { }); await doTest(async () => { + await addTopSites("https://example.com/"); await showResultByArrowDown(); await doPasteAndGo("www.example.com"); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js index 013bef1904..9227b81fd0 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js @@ -42,6 +42,7 @@ add_task(async function tabs() { await doTabTest({ trigger: async () => { const currentTab = gBrowser.selectedTab; + EventUtils.synthesizeKey("KEY_Tab"); EventUtils.synthesizeKey("KEY_Enter"); await BrowserTestUtils.waitForCondition( () => gBrowser.selectedTab !== currentTab @@ -50,14 +51,3 @@ add_task(async function tabs() { assert: () => assertEngagementTelemetry([{ search_mode: "tabs" }]), }); }); - -add_task(async function actions() { - await doActionsTest({ - trigger: async () => { - const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); - doClickSubButton(".urlbarView-quickaction-button[data-key=addons]"); - await onLoad; - }, - assert: () => assertEngagementTelemetry([{ search_mode: "actions" }]), - }); -}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js index bea266dbf4..34083e4369 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js @@ -119,9 +119,9 @@ add_task(async function selected_result_bookmark() { { selected_result: "bookmark", selected_result_subtype: "", - selected_position: 3, + selected_position: 2, provider: "Places", - results: "search_engine,action,bookmark", + results: "search_engine,bookmark", }, ]); }); @@ -267,27 +267,11 @@ add_task(async function selected_result_url() { }); }); -add_task(async function selected_result_action() { - await doTest(async () => { - await showResultByArrowDown(); - await selectRowByProvider("quickactions"); - const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); - doClickSubButton(".urlbarView-quickaction-button[data-key=addons]"); - await onLoad; - - assertEngagementTelemetry([ - { - selected_result: "action", - selected_result_subtype: "addons", - selected_position: 1, - provider: "quickactions", - results: "action", - }, - ]); +add_task(async function selected_result_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.secondaryActions.featureGate", false]], }); -}); -add_task(async function selected_result_tab() { const tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); await doTest(async () => { @@ -307,6 +291,7 @@ add_task(async function selected_result_tab() { ]); }); + await SpecialPowers.popPrefEnv(); BrowserTestUtils.removeTab(tab); }); @@ -402,7 +387,7 @@ add_task(async function selected_result_top_site() { selected_result_subtype: "", selected_position: 1, provider: "UrlbarProviderTopSites", - results: "top_site,action", + results: "top_site", }, ]); }); @@ -456,7 +441,7 @@ add_task(async function selected_result_clipboard() { selected_result_subtype: "", selected_position: 1, provider: "UrlbarProviderClipboard", - results: "clipboard,action", + results: "clipboard", }, ]); }); @@ -492,50 +477,6 @@ add_task(async function selected_result_unit() { await SpecialPowers.popPrefEnv(); }); -add_task(async function selected_result_site_specific_contextual_search() { - await SpecialPowers.pushPrefEnv({ - set: [["browser.urlbar.contextualSearch.enabled", true]], - }); - - await doTest(async () => { - const extension = await SearchTestUtils.installSearchExtension( - { - name: "Contextual", - search_url: "https://example.com/browser", - }, - { skipUnload: true } - ); - const onLoaded = BrowserTestUtils.browserLoaded( - gBrowser.selectedBrowser, - false, - "https://example.com/" - ); - BrowserTestUtils.startLoadingURIString( - gBrowser.selectedBrowser, - "https://example.com/" - ); - await onLoaded; - - await openPopup("search"); - await selectRowByProvider("UrlbarProviderContextualSearch"); - await doEnter(); - - assertEngagementTelemetry([ - { - selected_result: "site_specific_contextual_search", - selected_result_subtype: "", - selected_position: 2, - provider: "UrlbarProviderContextualSearch", - results: "search_engine,site_specific_contextual_search", - }, - ]); - - await extension.unload(); - }); - - await SpecialPowers.popPrefEnv(); -}); - add_task(async function selected_result_rs_adm_sponsored() { const cleanupQuickSuggest = await ensureQuickSuggestInit({ prefs: [["quicksuggest.rustEnabled", false]], diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js index 58c55b416f..dffdebad97 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js @@ -130,6 +130,7 @@ async function doTypedTest({ trigger, assert }) { async function doTypedWithResultsPopupTest({ trigger, assert }) { await doTest(async () => { + await addTopSites("https://example.org/"); await showResultByArrowDown(); EventUtils.synthesizeKey("x"); await UrlbarTestUtils.promiseSearchComplete(window); @@ -150,6 +151,7 @@ async function doPastedTest({ trigger, assert }) { async function doPastedWithResultsPopupTest({ trigger, assert }) { await doTest(async () => { + await addTopSites("https://example.org/"); await showResultByArrowDown(); await doPaste("x"); @@ -257,6 +259,7 @@ async function doPersistedSearchTermsRestartedRefinedTest({ for (const { firstInput, secondInput, expected } of testData) { await doTest(async () => { + await addTopSites("https://example.com/"); await openPopup(firstInput); await doEnter(); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js index 86151e1ba3..25a6504109 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js @@ -78,16 +78,3 @@ async function doTabTest({ trigger, assert }) { BrowserTestUtils.removeTab(tab); } - -async function doActionsTest({ trigger, assert }) { - await doTest(async () => { - await openPopup("add"); - await UrlbarTestUtils.enterSearchMode(window, { - source: UrlbarUtils.RESULT_SOURCE.ACTIONS, - }); - await selectRowByProvider("quickactions"); - - await trigger(); - await assert(); - }); -} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js index 1373cc7e27..a3b2b020c0 100644 --- a/browser/components/urlbar/tests/engagementTelemetry/browser/head.js +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js @@ -406,9 +406,7 @@ async function setup() { set: [ ["browser.urlbar.searchEngagementTelemetry.enabled", true], ["browser.urlbar.quickactions.enabled", true], - ["browser.urlbar.quickactions.minimumSearchString", 0], - ["browser.urlbar.suggest.quickactions", true], - ["browser.urlbar.shortcuts.quickactions", true], + ["browser.urlbar.secondaryActions.featureGate", true], ], }); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js index 98f6ba6117..87f94a641b 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js @@ -34,6 +34,27 @@ const REMOTE_SETTINGS_RESULTS = [ }, ]; +const MERINO_NAVIGATIONAL_SUGGESTION = { + url: "https://example.com/navigational-suggestion", + title: "Navigational suggestion", + provider: "top_picks", + is_sponsored: false, + score: 0.25, + block_id: 0, + is_top_pick: true, +}; + +const MERINO_DYNAMIC_WIKIPEDIA_SUGGESTION = { + url: "https://example.com/dynamic-wikipedia", + title: "Dynamic Wikipedia suggestion", + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "dynamic-wikipedia", + provider: "wikipedia", + iab_category: "5 - Education", + block_id: 1, +}; + add_setup(async function () { await PlacesUtils.history.clear(); await PlacesUtils.bookmarks.eraseEverything(); @@ -46,7 +67,11 @@ add_setup(async function () { attachment: REMOTE_SETTINGS_RESULTS, }, ], + merinoSuggestions: [], }); + + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); }); // Tests a sponsored result and keyword highlighting. @@ -167,30 +192,9 @@ add_tasks_with_rust( // 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); + await doManageTest({ + input: "fra", + index: 1, }); }); @@ -201,3 +205,35 @@ add_tasks_with_rust(async function resultMenu_manage_nonSponsored() { index: 1, }); }); + +// Tests the "Manage" result menu for Navigational suggestion. +add_tasks_with_rust(async function resultMenu_manage_navigational() { + // Enable Merino. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_NAVIGATIONAL_SUGGESTION, + ]; + + await doManageTest({ + input: "test", + index: 1, + }); + + UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); +}); + +// Tests the "Manage" result menu for Dynamic Wikipedia suggestion. +add_tasks_with_rust(async function resultMenu_manage_dynamicWikipedia() { + // Enable Merino. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_DYNAMIC_WIKIPEDIA_SUGGESTION, + ]; + + await doManageTest({ + input: "test", + index: 1, + }); + + UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); +}); 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 71c289e0ef..c659eee268 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js @@ -88,24 +88,6 @@ add_task(async function () { }, }, }, - // help - { - command: "help", - scalars: { - [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, - [TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA]: position, - }, - event: { - category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, - method: "engagement", - object: "help", - extra: { - suggestion_type, - match_type, - position: position.toString(), - }, - }, - }, ], }); }); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js index e87c64740f..90044a95bd 100644 --- a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js @@ -121,24 +121,6 @@ add_task(async function () { }, }, }, - // help - { - command: "help", - scalars: { - [WEATHER_SCALARS.IMPRESSION]: position, - [WEATHER_SCALARS.HELP]: position, - }, - event: { - category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, - method: "engagement", - object: "help", - extra: { - suggestion_type, - match_type, - position: position.toString(), - }, - }, - }, ], }); }); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js index a9f339c324..7fc687d3df 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js @@ -90,14 +90,11 @@ function makeExpectedResult() { qsSuggestion: "full_keyword", source: "merino", provider: "wikipedia", - 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, }, }; } diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js index 64f4991236..b88e14e0e0 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js @@ -549,14 +549,11 @@ add_task(async function bestMatch() { url: "url", icon: null, qsSuggestion: "full_keyword", - 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, displayUrl: "url", source: "merino", provider: "some_top_pick_provider", diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js index 224dd6cb22..89fefd3163 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js @@ -657,14 +657,11 @@ function makeExpectedDefaultResult({ suggestion }) { ? { id: "urlbar-result-action-sponsored" } : undefined, shouldShowUrl: true, - 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, }, }; } diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js index 1b8da54920..468cedbe0b 100644 --- a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js @@ -175,14 +175,11 @@ function makeExpectedResult({ shouldShowUrl: true, source: "merino", provider: telemetryType, - 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, }, }; if (typeof dupedHeuristic == "boolean") { diff --git a/browser/components/urlbar/tests/unit/test_autofill_origins.js b/browser/components/urlbar/tests/unit/test_autofill_origins.js index 33e462a8af..454881b933 100644 --- a/browser/components/urlbar/tests/unit/test_autofill_origins.js +++ b/browser/components/urlbar/tests/unit/test_autofill_origins.js @@ -1039,3 +1039,68 @@ async function doTitleTest({ visits, input, expected }) { await cleanup(); } + +/* Tests sorting order when only unvisited bookmarks are available (e.g. in + permanent private browsing mode), then the only information we have is the + number of bookmarks per origin, and we're going to use that. */ +add_task(async function just_multiple_unvisited_bookmarks() { + // These are sorted to avoid confusion with natural sorting, so the one with + // the highest score is added in the middle. + let filledUrl = "https://www.tld2.com/"; + let urls = [ + { + url: "https://tld1.com/", + count: 1, + }, + { + url: "https://tld2.com/", + count: 2, + }, + { + url: filledUrl, + count: 2, + }, + { + url: "https://tld3.com/", + count: 3, + }, + ]; + + await PlacesUtils.history.clear(); + for (let { url, count } of urls) { + while (count--) { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: url, + }); + } + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let context = createContext("tld", { isPrivate: false }); + await check_results({ + context, + autofilled: "tld2.com/", + completed: filledUrl, + matches: [ + makeVisitResult(context, { + uri: filledUrl, + title: "A bookmark", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "https://tld3.com/", + title: "A bookmark", + }), + makeBookmarkResult(context, { + uri: "https://tld2.com/", + title: "A bookmark", + }), + makeBookmarkResult(context, { + uri: "https://tld1.com/", + title: "A bookmark", + }), + ], + }); + + await cleanup(); +}); diff --git a/browser/components/urlbar/tests/unit/test_quickactions.js b/browser/components/urlbar/tests/unit/test_quickactions.js index 00206c77b2..30e3fbdd95 100644 --- a/browser/components/urlbar/tests/unit/test_quickactions.js +++ b/browser/components/urlbar/tests/unit/test_quickactions.js @@ -5,108 +5,58 @@ "use strict"; ChromeUtils.defineESModuleGetters(this, { - UrlbarProviderQuickActions: - "resource:///modules/UrlbarProviderQuickActions.sys.mjs", + ActionsProviderQuickActions: + "resource:///modules/ActionsProviderQuickActions.sys.mjs", }); -let expectedMatch = (key, inputLength) => ({ - type: UrlbarUtils.RESULT_TYPE.DYNAMIC, - source: UrlbarUtils.RESULT_SOURCE.ACTIONS, - heuristic: false, - payload: { - results: [{ key }], - dynamicType: "quickactions", - inQuickActionsSearchMode: false, - helpUrl: UrlbarProviderQuickActions.helpUrl, - inputLength, - }, -}); - -testEngine_setup(); - add_setup(async () => { UrlbarPrefs.set("quickactions.enabled", true); - UrlbarPrefs.set("suggest.quickactions", true); - UrlbarProviderQuickActions.addAction("newaction", { + ActionsProviderQuickActions.addAction("newaction", { commands: ["newaction"], }); registerCleanupFunction(async () => { UrlbarPrefs.clear("quickactions.enabled"); - UrlbarPrefs.clear("suggest.quickactions"); - UrlbarProviderQuickActions.removeAction("newaction"); + ActionsProviderQuickActions.removeAction("newaction"); }); }); add_task(async function nomatch() { - let context = createContext("this doesnt match", { - providers: [UrlbarProviderQuickActions.name], - isPrivate: false, - }); - await check_results({ - context, - matches: [], - }); -}); - -add_task(async function quickactions_disabled() { - UrlbarPrefs.set("suggest.quickactions", false); - let context = createContext("new", { - providers: [UrlbarProviderQuickActions.name], - isPrivate: false, - }); - await check_results({ - context, - matches: [], - }); + let context = createContext("this doesnt match", {}); + let result = await ActionsProviderQuickActions.queryAction(context); + Assert.ok(result === null, "there were no matches"); }); add_task(async function quickactions_match() { - UrlbarPrefs.set("suggest.quickactions", true); - let context = createContext("new", { - providers: [UrlbarProviderQuickActions.name], - isPrivate: false, - }); - await check_results({ - context, - matches: [expectedMatch("newaction", 3)], - }); + let context = createContext("new", {}); + let result = await ActionsProviderQuickActions.queryAction(context); + Assert.ok(result.key == "newaction", "Matched the new action"); }); add_task(async function duplicate_matches() { - UrlbarProviderQuickActions.addAction("testaction", { + ActionsProviderQuickActions.addAction("testaction", { commands: ["testaction", "test"], }); - let context = createContext("testaction", { - providers: [UrlbarProviderQuickActions.name], - isPrivate: false, - }); + let context = createContext("test", {}); + let result = await ActionsProviderQuickActions.queryAction(context); - await check_results({ - context, - matches: [expectedMatch("testaction", 10)], - }); + Assert.ok(result.key == "testaction", "Matched the test action"); - UrlbarProviderQuickActions.removeAction("testaction"); + ActionsProviderQuickActions.removeAction("testaction"); }); add_task(async function remove_action() { - UrlbarProviderQuickActions.addAction("testaction", { + ActionsProviderQuickActions.addAction("testaction", { commands: ["testaction"], }); - UrlbarProviderQuickActions.removeAction("testaction"); + ActionsProviderQuickActions.removeAction("testaction"); - let context = createContext("test", { - providers: [UrlbarProviderQuickActions.name], - isPrivate: false, - }); + let context = createContext("test", {}); + let result = await ActionsProviderQuickActions.queryAction(context); - await check_results({ - context, - matches: [], - }); + Assert.ok(result === null, "there were no matches"); }); add_task(async function minimum_search_string() { @@ -114,13 +64,18 @@ add_task(async function minimum_search_string() { for (let minimumSearchString of [0, 3]) { UrlbarPrefs.set("quickactions.minimumSearchString", minimumSearchString); for (let i = 1; i < 4; i++) { - let context = createContext(searchString.substring(0, i), { - providers: [UrlbarProviderQuickActions.name], - isPrivate: false, - }); - let matches = - i >= minimumSearchString ? [expectedMatch("newaction", i)] : []; - await check_results({ context, matches }); + let context = createContext(searchString.substring(0, i), {}); + let result = await ActionsProviderQuickActions.queryAction(context); + + if (i >= minimumSearchString) { + Assert.ok(result.key == "newaction", "Matched the new action"); + } else { + Assert.equal( + ActionsProviderQuickActions.isActive(context), + false, + "QuickActions Provider is not active" + ); + } } } UrlbarPrefs.clear("quickactions.minimumSearchString"); diff --git a/browser/components/urlbar/tests/unit/test_tokenizer.js b/browser/components/urlbar/tests/unit/test_tokenizer.js index 835d1a5909..76b2c31ac2 100644 --- a/browser/components/urlbar/tests/unit/test_tokenizer.js +++ b/browser/components/urlbar/tests/unit/test_tokenizer.js @@ -32,6 +32,12 @@ add_task(async function test_tokenizer() { ], }, { + desc: "do not separate restriction char at beginning in search mode", + searchMode: { engineName: "testEngine" }, + searchString: `${UrlbarTokenizer.RESTRICT.SEARCH}test`, + expectedTokens: [{ value: "?test", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { desc: "separate restriction char at end", searchString: `test ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, expectedTokens: [ |